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? _timeline; StreamSubscription? _roomUpdateSub; Timer? _redecryptTimer; @override Future 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(); _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)); } }); }); // 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(); _redecryptTimer?.cancel(); _timeline?.cancelSubscriptions(); _timeline = null; }); return timeline; } void _onUpdate() { final t = _timeline; if (t != null) state = AsyncData(t); } } final timelineProvider = AsyncNotifierProvider.family( TimelineNotifier.new, ); Future _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('session_id') ?? ''; diag.add(E2eeDiagEntry( timestamp: DateTime.now(), roomId: roomId, eventId: event.eventId, sessionId: sessionId, error: decrypted.content.tryGet('body') ?? 'unknown', )); } } catch (e) { final sessionId = event.content.tryGet('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 _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((ref, roomId) { final client = ref.watch(matrixClientProvider).valueOrNull; return client?.getRoomById(roomId); });