fix(ui): UX-Audit – Sackgassen, tote Knöpfe, englische Reste; Screenshot-Tour

Bernds Wunsch 2026-10-08: UI grundsätzlich prüfen (Navigation, Sackgassen,
Anruf-Logik). Audit + Konzept in docs/UI_AUDIT.md, belegt mit neuer
Screenshot-Tour integration_test/ui_audit_tour_test.dart (35 Zustände,
Handy + PC, zwei Testkonten, Test-Space mit Sprachkanal).

Sofort behoben:
- S1/S2: Gegenseite legt auf / Anruf scheitert / Sprachkanal bricht ab →
  automatisch zurück zum Chat mit Hinweis (Fehler sichtbar statt still);
  leere Anrufansicht deutsch mit Zurück-Knopf (vorher englische Sackgasse
  ohne Zurück am Handy).
- S4: toter „Threads“-Knopf entfernt. S5: Space betreten (PC) öffnet
  letzten bzw. ersten Textkanal. S6: keine doppelte Anrufsteuerung am PC.
- Annehmen-Fenster mit Anrufer-Avatar und Anrufart; eigene Kachel mit
  eigenem Initial statt „D“; Mitgliederliste rund mit Profilbild und „(Du)“;
  Tastaturkürzel nur am PC; englische Texte in Anruf/Leerzuständen deutsch.
- Tests: Fehlerempfänger des Testrahmens nach app.main() zurück (vorher
  verschwanden Fehlermeldungen), Lesebestätigungs-Test im Wegwerf-Raum statt
  in der DM, robustere Schritte; test.ps1 räumt liegengebliebene
  Test-Prozesse weg; gemeinsame Hilfen integration_test/support/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-09 11:35:40 +02:00
co-authored by Claude Opus 5.5
parent 842dd61031
commit 87b472a373
22 changed files with 934 additions and 378 deletions
+54 -285
View File
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@@ -10,9 +9,10 @@ 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';
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
@@ -29,14 +29,18 @@ import 'package:webrtc_interface/webrtc_interface.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
final accounts = _loadAccounts();
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);
await pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty);
FlutterError.onError = testOnError;
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
@@ -46,7 +50,7 @@ void main() {
expect(me.userID, '@pyramidtest1:steggi-matrix.work');
final partner = (await tester.runAsync(
() => _Partner.start(accounts!['user2']!, accounts['pass2']!),
() => TestPartner.start(accounts!['user2']!, accounts['pass2']!),
))!;
String? roomId;
addTearDown(() => tester.runAsync(() async {
@@ -62,24 +66,24 @@ void main() {
preset: CreateRoomPreset.privateChat,
name: 'Anruftest (automatisch)',
));
_log('Raum angelegt: $roomId');
await _pumpUntil(
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());
_log('App ist beigetreten');
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));
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');
logStep('Teil 1: Gegenseite ruft an');
await tester.runAsync(() => partner.run(() => partner.voip.inviteToCall(
partner.client.getRoomById(roomId!)!,
CallType.kVoice,
@@ -87,9 +91,9 @@ void main() {
final answerBtn = find.byWidgetPredicate(
(w) => w is FloatingActionButton && w.heroTag == 'answer_call',
);
await _pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty,
await pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty,
what: 'Annehmen-Knopf erscheint');
await _pumpFor(tester, const Duration(seconds: 2)); // klingeln lassen
await pumpFor(tester, const Duration(seconds: 2)); // klingeln lassen
await tester.tap(answerBtn.first);
await _waitConnected(tester, voip, partner);
@@ -98,8 +102,8 @@ void main() {
// Stumm über den echten Knopf → Gegenseite sieht „stumm“, Spur ist aus.
bool partnerSeesMuted() =>
partner.delegate.call?.remoteUserMediaStream?.audioMuted == true;
await tester.tap(find.byIcon(Icons.mic_rounded).first);
await _pumpUntil(tester, partnerSeesMuted,
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);
@@ -108,15 +112,15 @@ void main() {
isFalse,
reason: 'Mikrofonspur ist trotz Stumm noch an',
);
await tester.tap(find.byIcon(Icons.mic_off_rounded).first);
await _pumpUntil(tester, () => !partnerSeesMuted() && !voip.isMicMuted,
await tester.tap(_ctrlBtn(Icons.mic_off_rounded));
await pumpUntil(tester, () => !partnerSeesMuted() && !voip.isMicMuted,
timeout: const Duration(seconds: 15),
what: 'Stumm wieder aufgehoben');
_log('Stumm/Entstummen über den Knopf: ok');
logStep('Stumm/Entstummen über den Knopf: ok');
// Taub → Gegenseite nicht mehr hören UND selbst stumm; danach zurück.
await tester.tap(find.byIcon(Icons.headphones_rounded).first);
await _pumpUntil(
await tester.tap(_ctrlBtn(Icons.headphones_rounded));
await pumpUntil(
tester,
() => voip.isDeafened && partnerSeesMuted(),
timeout: const Duration(seconds: 15),
@@ -127,8 +131,8 @@ void main() {
expect(remoteAudio, isNotEmpty);
expect(remoteAudio.every((t) => !t.enabled), isTrue,
reason: 'Ton der Gegenseite ist trotz Taub noch an');
await tester.tap(find.byIcon(Icons.headset_off_rounded).first);
await _pumpUntil(
await tester.tap(_ctrlBtn(Icons.headset_off_rounded));
await pumpUntil(
tester,
() => !voip.isDeafened && !partnerSeesMuted() && !voip.isMicMuted,
timeout: const Duration(seconds: 15),
@@ -140,13 +144,13 @@ void main() {
.every((t) => t.enabled),
isTrue,
);
_log('Taub/Hören über den Knopf: ok');
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(
await pumpUntil(
tester,
() =>
voip.isRemoteScreensharing &&
@@ -154,21 +158,21 @@ void main() {
timeout: const Duration(seconds: 30),
what: 'Bildschirm der Gegenseite erscheint in der App',
);
await _pumpFor(tester, const Duration(seconds: 3));
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');
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,
await pumpUntil(tester, () => !voip.isRemoteScreensharing,
what: 'Bildschirm der Gegenseite verschwindet wieder');
// App teilt ihren Bildschirm über den echten Auswähler
await _pumpUntil(
await pumpUntil(
tester,
() => find.byIcon(Icons.screen_share_rounded).evaluate().isNotEmpty,
what: 'Teilen-Knopf in der Anrufleiste',
@@ -177,10 +181,10 @@ void main() {
final tile = find.byWidgetPredicate(
(w) => w.runtimeType.toString() == '_SourceTile',
);
await _pumpUntil(tester, () => tile.evaluate().isNotEmpty,
await pumpUntil(tester, () => tile.evaluate().isNotEmpty,
timeout: const Duration(seconds: 20), what: 'Quellen-Auswähler');
await tester.tap(tile.first);
await _pumpUntil(
await pumpUntil(
tester,
() {
final s = partner.delegate.call?.remoteScreenSharingStream?.stream;
@@ -191,27 +195,27 @@ void main() {
timeout: const Duration(seconds: 30),
what: 'Bildschirm der App kommt bei der Gegenseite an',
);
await _pumpFor(tester, const Duration(seconds: 3));
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');
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,
await pumpUntil(tester, () => voip.currentCall == null,
what: 'aufgelegt (Teil 1)');
await _pumpFor(tester, const Duration(seconds: 3));
await pumpFor(tester, const Duration(seconds: 3));
// ═══ Teil 2: App ruft an, Gegenseite nimmt an ═══
_log('Teil 2: App ruft 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,
await pumpUntil(tester, () => voip.currentCall == null,
what: 'aufgelegt (Teil 2)');
},
skip: profile == null || accounts == null,
@@ -222,10 +226,10 @@ void main() {
Future<void> _waitConnected(
WidgetTester tester,
CallSignalingService voip,
_Partner partner,
TestPartner partner,
) async {
final t0 = DateTime.now();
await _pumpUntil(
await pumpUntil(
tester,
() =>
voip.currentCall?.state == CallState.kConnected &&
@@ -235,26 +239,26 @@ Future<void> _waitConnected(
every5s: () {
final a = voip.currentCall;
final b = partner.delegate.call;
_log('App: ${a?.state} ice=${a?.pc?.iceConnectionState} | '
logStep('App: ${a?.state} ice=${a?.pc?.iceConnectionState} | '
'Gegenseite: ${b?.state} ice=${b?.pc?.iceConnectionState}');
},
);
_log('verbunden nach ${DateTime.now().difference(t0).inMilliseconds} ms');
logStep('verbunden nach ${DateTime.now().difference(t0).inMilliseconds} ms');
}
Future<void> _checkAudio(
WidgetTester tester,
CallSignalingService voip,
_Partner partner,
TestPartner partner,
) async {
await _pumpFor(tester, const Duration(seconds: 4));
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');
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),
@@ -280,246 +284,11 @@ Future<_Rtp> _rtp(rtc.RTCPeerConnection pc, String kind) async {
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<Map<String, dynamic>>('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<T> run<T>(Future<T> Function() f) {
final done = Completer<T>();
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 = <Map<String, dynamic>>[];
final _applied = <String>{};
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<void> 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<RTCPeerConnection> createPeerConnection(
Map<String, dynamic> configuration, [
Map<String, dynamic> constraints = const {},
]) =>
rtc.createPeerConnection(configuration, constraints);
@override
Future<void> 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<void> handleCallEnded(CallSession session) async {}
@override
Future<void> handleMissedCall(CallSession session) async {}
@override
Future<void> handleNewGroupCall(GroupCallSession groupCall) async {}
@override
Future<void> handleGroupCallEnded(GroupCallSession groupCall) async {}
@override
Future<void> playRingtone() async {}
@override
Future<void> stopRingtone() async {}
@override
Future<void> 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<MediaStream> getUserMedia(Map<String, dynamic> c) => _d.getUserMedia(c);
@override
Future<MediaStream> getDisplayMedia(Map<String, dynamic> 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<List<MediaDeviceInfo>> 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<List<dynamic>> getSources() => _d.getSources();
@override
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) =>
_d.selectAudioOutput(options);
}
Map<String, String>? _loadAccounts() {
final home = Platform.environment['USERPROFILE'] ?? '';
final file = File('$home\\.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;
}
Future<void> _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<void> _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');
}
/// 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;