25ed765a03
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.) als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
148 lines
4.5 KiB
Dart
148 lines
4.5 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;
|
|
Timer? _redecryptTimer;
|
|
|
|
@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();
|
|
_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, 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);
|
|
});
|