wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
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
This commit is contained in:
@@ -1,20 +1,145 @@
|
||||
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 =
|
||||
FutureProvider.family<Timeline, String>((ref, roomId) async {
|
||||
final client = await ref.watch(matrixClientProvider.future);
|
||||
final room = client.getRoomById(roomId);
|
||||
if (room == null) throw Exception('Raum nicht gefunden: $roomId');
|
||||
AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>(
|
||||
TimelineNotifier.new,
|
||||
);
|
||||
|
||||
final timeline = await room.getTimeline(
|
||||
onUpdate: () => ref.invalidateSelf(),
|
||||
);
|
||||
Future<void> _decryptLegacyEvents(
|
||||
Client client,
|
||||
Timeline timeline,
|
||||
String roomId,
|
||||
E2eeDiagnosticsNotifier diag,
|
||||
) async {
|
||||
final enc = client.encryption;
|
||||
if (enc == null) return;
|
||||
|
||||
ref.onDispose(timeline.cancelSubscriptions);
|
||||
return timeline;
|
||||
});
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user