Bernds Test mit der PC-Testinstanz: - ✓✓ erschien erst nach erneutem Öffnen: eine Lesebestätigung ohne neue Nachricht zeichnete nichts neu (Raum-Update kommt im SDK vor der Bestätigung). Jetzt nach jedem Sync Fingerabdruck der Lesepositionen prüfen und bei Änderung neu zeichnen. - Einladung: Chat zeigte leeren Verlauf, Annehmen ging nur in der Liste, und danach blieb der Chat leer bis zum App-Neustart. Jetzt Annehmen/Ablehnen direkt im Chat; Annehmen wartet auf den Beitritts-Sync, der Verlauf baut sich bei invite→join selbst neu auf. - Trenner blieb stehen, wenn nach dem Öffnen/Zurückkommen nicht gescrollt wurde. Jetzt: neueste Nachrichten sichtbar (≤ halbe Bildschirmhöhe hochgescrollt) → nach 6 s weich ausblenden, auch ohne Scrollen. - Live-Test mit beiden Testkonten (Gegenprobe: ohne Fix rot) in scripts/test.ps1 -Ui; Testlauf-Sperre erkennt nur noch eigene Testläufe. Co-Authored-By: Claude Opus 5.5 <[email protected]>
204 lines
6.4 KiB
Dart
204 lines
6.4 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:matrix/matrix.dart';
|
||
import 'package:pyramid/core/e2ee_diagnostics.dart';
|
||
import 'package:pyramid/core/matrix_client.dart';
|
||
|
||
// AsyncNotifier so onUpdate can refresh state in-place without recreating the
|
||
// timeline (which would reset the loaded event window back to the default 30).
|
||
class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
||
Timeline? _timeline;
|
||
StreamSubscription<String>? _roomUpdateSub;
|
||
StreamSubscription<SyncUpdate>? _syncSub;
|
||
Timer? _redecryptTimer;
|
||
String _receiptSig = '';
|
||
|
||
@override
|
||
Future<Timeline> build(String arg) async {
|
||
final roomId = arg;
|
||
final client = await ref.watch(matrixClientProvider.future);
|
||
final room = client.getRoomById(roomId);
|
||
if (room == null) throw Exception('Raum nicht gefunden: $roomId');
|
||
|
||
_timeline?.cancelSubscriptions();
|
||
_roomUpdateSub?.cancel();
|
||
_syncSub?.cancel();
|
||
_redecryptTimer?.cancel();
|
||
|
||
final diag = ref.read(e2eeDiagnosticsProvider.notifier);
|
||
|
||
final timeline = await room.getTimeline(
|
||
onUpdate: _onUpdate,
|
||
);
|
||
_timeline = timeline;
|
||
|
||
if (timeline.events.length < 20) {
|
||
await timeline.requestHistory(historyCount: 60);
|
||
}
|
||
|
||
timeline.requestKeys(onlineKeyBackupOnly: false);
|
||
unawaited(_decryptLegacyEvents(client, timeline, roomId, diag));
|
||
|
||
// Re-decrypt whenever the room updates (e.g. after new keys arrive from
|
||
// key requests). Debounced to avoid redundant passes on rapid updates.
|
||
_roomUpdateSub = room.onUpdate.stream.listen((_) {
|
||
_redecryptTimer?.cancel();
|
||
_redecryptTimer = Timer(const Duration(milliseconds: 800), () {
|
||
final t = _timeline;
|
||
if (t != null) {
|
||
unawaited(_decryptLegacyEvents(client, t, roomId, diag));
|
||
}
|
||
});
|
||
});
|
||
|
||
// Nach jedem Sync (erst dann sind Lesebestätigungen verarbeitet – das
|
||
// Raum-Update kommt im SDK VORHER):
|
||
// • Einladung angenommen (invite → join): Verlauf neu aufbauen; der beim
|
||
// Einladen gebaute blieb sonst leer, bis man die App neu startete.
|
||
// • Lesebestätigung eines anderen hat sich geändert: neu zeichnen, damit
|
||
// ✓✓ live erscheint. Neue Nachrichten allein lösen _onUpdate schon aus,
|
||
// eine Bestätigung ohne neue Nachricht bisher nicht.
|
||
final membershipAtBuild = room.membership;
|
||
_receiptSig = _receiptSignature(room);
|
||
_syncSub = client.onSync.stream.listen((_) {
|
||
if (membershipAtBuild != Membership.join &&
|
||
room.membership == Membership.join) {
|
||
ref.invalidateSelf();
|
||
return;
|
||
}
|
||
final sig = _receiptSignature(room);
|
||
if (sig != _receiptSig) {
|
||
_receiptSig = sig;
|
||
_onUpdate();
|
||
}
|
||
});
|
||
|
||
// Ensure device keys are downloaded for all room members so outbound
|
||
// Megolm session key sharing works on the first send.
|
||
if (room.encrypted && client.encryptionEnabled) {
|
||
unawaited(_preloadDeviceKeys(client, room));
|
||
}
|
||
|
||
ref.onDispose(() {
|
||
_roomUpdateSub?.cancel();
|
||
_syncSub?.cancel();
|
||
_redecryptTimer?.cancel();
|
||
_timeline?.cancelSubscriptions();
|
||
_timeline = null;
|
||
});
|
||
return timeline;
|
||
}
|
||
|
||
void _onUpdate() {
|
||
final t = _timeline;
|
||
if (t != null) state = AsyncData(t);
|
||
}
|
||
}
|
||
|
||
/// Kurzer Fingerabdruck der Lesepositionen der anderen Mitglieder.
|
||
String _receiptSignature(Room room) {
|
||
final b = StringBuffer();
|
||
for (final users in [
|
||
room.receiptState.global.otherUsers,
|
||
room.receiptState.mainThread?.otherUsers ?? const {},
|
||
]) {
|
||
users.forEach((userId, data) => b
|
||
..write(userId)
|
||
..write('=')
|
||
..write(data.eventId)
|
||
..write(';'));
|
||
}
|
||
return b.toString();
|
||
}
|
||
|
||
/// Einladung annehmen und warten, bis der Raum als beigetreten
|
||
/// synchronisiert ist – erst dann hat der Verlauf Inhalt.
|
||
Future<void> acceptInvite(WidgetRef ref, Room room) async {
|
||
final client = room.client;
|
||
// Vor dem Beitreten anfangen zu warten, sonst kann der Sync durchrutschen.
|
||
final synced = client
|
||
.waitForRoomInSync(room.id, join: true)
|
||
.timeout(const Duration(seconds: 20))
|
||
.then<void>((_) {}, onError: (_) {});
|
||
await room.join();
|
||
await synced;
|
||
ref.invalidate(timelineProvider(room.id));
|
||
}
|
||
|
||
final timelineProvider =
|
||
AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>(
|
||
TimelineNotifier.new,
|
||
);
|
||
|
||
Future<void> _decryptLegacyEvents(
|
||
Client client,
|
||
Timeline timeline,
|
||
String roomId,
|
||
E2eeDiagnosticsNotifier diag,
|
||
) async {
|
||
final enc = client.encryption;
|
||
if (enc == null) return;
|
||
|
||
try {
|
||
await enc.keyManager.loadAllKeysFromRoom(roomId);
|
||
} catch (e) {
|
||
Logs().e('[ChatProvider] loadAllKeysFromRoom failed', e);
|
||
}
|
||
|
||
if (!enc.enabled) return;
|
||
|
||
var changed = false;
|
||
for (var i = 0; i < timeline.events.length; i++) {
|
||
final event = timeline.events[i];
|
||
if (event.type != EventTypes.Encrypted) continue;
|
||
try {
|
||
final decrypted = await enc.decryptRoomEvent(
|
||
event,
|
||
store: true,
|
||
updateType: EventUpdateType.history,
|
||
);
|
||
if (decrypted.type != EventTypes.Encrypted) {
|
||
timeline.events[i] = decrypted;
|
||
changed = true;
|
||
} else {
|
||
// Still encrypted — log for diagnostics
|
||
final sessionId = event.content.tryGet<String>('session_id') ?? '';
|
||
diag.add(E2eeDiagEntry(
|
||
timestamp: DateTime.now(),
|
||
roomId: roomId,
|
||
eventId: event.eventId,
|
||
sessionId: sessionId,
|
||
error: decrypted.content.tryGet<String>('body') ?? 'unknown',
|
||
));
|
||
}
|
||
} catch (e) {
|
||
final sessionId = event.content.tryGet<String>('session_id') ?? '';
|
||
diag.add(E2eeDiagEntry(
|
||
timestamp: DateTime.now(),
|
||
roomId: roomId,
|
||
eventId: event.eventId,
|
||
sessionId: sessionId,
|
||
error: e.toString(),
|
||
));
|
||
}
|
||
}
|
||
// Notify UI of in-place changes only when something actually decrypted
|
||
if (changed) timeline.onUpdate?.call();
|
||
}
|
||
|
||
Future<void> _preloadDeviceKeys(Client client, Room room) async {
|
||
try {
|
||
final members = await room.requestParticipants([Membership.join, Membership.invite]);
|
||
final userIds = members.map((m) => m.id).toSet();
|
||
await client.updateUserDeviceKeys(additionalUsers: userIds);
|
||
} catch (e) {
|
||
Logs().w('[ChatProvider] Device key preload failed: $e');
|
||
}
|
||
}
|
||
|
||
final roomProvider = Provider.family<Room?, String>((ref, roomId) {
|
||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||
return client?.getRoomById(roomId);
|
||
});
|