import 'dart:collection'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:path_provider/path_provider.dart'; /// Shared, bounded LRU cache for media bytes (chat images, document previews, /// avatars). Eviction is driven by a total byte budget plus a hard entry cap, /// so the most-recently-viewed media (i.e. your recent messages) stays instantly /// available while older media is dropped and re-fetched on demand. /// /// In addition to the in-memory layer there is a small disk layer for /// persistent entries (avatars, banners), so they appear instantly after an /// app restart instead of popping in once re-downloaded. class MediaCache { MediaCache._(); static final MediaCache instance = MediaCache._(); // ~256 MB in memory, capped at 300 entries — comfortably covers the last // ~100 messages of media per active chat without unbounded growth. static const int _maxBytes = 256 * 1024 * 1024; static const int _maxEntries = 300; // Don't cache very large single files (videos etc.) — they'd evict everything. static const int _maxEntryBytes = 32 * 1024 * 1024; // Disk layer: only small entries (avatars, banners), bounded total size. static const int _maxDiskEntryBytes = 1024 * 1024; // 1 MB pro Eintrag static const int _maxDiskTotalBytes = 64 * 1024 * 1024; // 64 MB gesamt final LinkedHashMap _entries = LinkedHashMap(); int _totalBytes = 0; Directory? _diskDir; bool _pruneScheduled = false; Uint8List? get(String key) { final v = _entries.remove(key); if (v != null) _entries[key] = v; // move to most-recently-used position return v; } void put(String key, Uint8List bytes) { if (bytes.lengthInBytes > _maxEntryBytes) return; final existing = _entries.remove(key); if (existing != null) _totalBytes -= existing.lengthInBytes; _entries[key] = bytes; _totalBytes += bytes.lengthInBytes; _evict(); } /// Memory lookup first, then disk. Disk hits are promoted back into memory. Future getPersistent(String key) async { final mem = get(key); if (mem != null) return mem; try { final file = await _diskFile(key); if (await file.exists()) { final bytes = await file.readAsBytes(); if (bytes.isNotEmpty) { put(key, bytes); // Touch für LRU-Pruning nach mtime. file.setLastModified(DateTime.now()).catchError((_) {}); return bytes; } } } catch (_) {} return null; } /// Stores in memory and (for small entries) on disk, surviving restarts. Future putPersistent(String key, Uint8List bytes) async { put(key, bytes); if (bytes.lengthInBytes > _maxDiskEntryBytes) return; try { final file = await _diskFile(key); await file.writeAsBytes(bytes); } catch (_) {} } Future _diskFile(String key) async { final dir = _diskDir ??= await _initDiskDir(); final name = sha1.convert(utf8.encode(key)).toString(); return File('${dir.path}${Platform.pathSeparator}$name.bin'); } Future _initDiskDir() async { final support = await getApplicationSupportDirectory(); final dir = Directory( '${support.path}${Platform.pathSeparator}media_cache'); await dir.create(recursive: true); _schedulePrune(dir); return dir; } /// Once per session: drop the oldest disk entries when over budget. void _schedulePrune(Directory dir) { if (_pruneScheduled) return; _pruneScheduled = true; Future(() async { try { final files = {}; var total = 0; await for (final e in dir.list()) { if (e is! File) continue; final stat = await e.stat(); files[e] = stat; total += stat.size; } if (total <= _maxDiskTotalBytes) return; final sorted = files.entries.toList() ..sort((a, b) => a.value.modified.compareTo(b.value.modified)); for (final entry in sorted) { if (total <= _maxDiskTotalBytes) break; total -= entry.value.size; await entry.key.delete().catchError((_) => entry.key); } } catch (_) {} }); } void _evict() { while ((_totalBytes > _maxBytes || _entries.length > _maxEntries) && _entries.isNotEmpty) { final oldestKey = _entries.keys.first; final removed = _entries.remove(oldestKey); if (removed != null) _totalBytes -= removed.lengthInBytes; } } void clear() { _entries.clear(); _totalBytes = 0; } }