Files
pyramid/lib/core/media_cache.dart
T
Bernd Steckmeister dba4b64df8 feat: PYRAMID_PROFILE_DIR-Override fuer Desktop-Testprofile (app_dirs.dart)
DB, auth_log und Media-Cache laufen jetzt ueber appSupportDir(); nur auf
Desktop und nur wenn PYRAMID_PROFILE_DIR gesetzt ist, landet alles in einem
eigenen Profilverzeichnis - fuer Autopilot-Testlogins parallel zur echten
Session. Ohne Variable exakt das bisherige Verhalten. secure_storage/
shared_preferences bewusst nicht umgeleitet (Begruendung im Dateikopf).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:55:30 +02:00

140 lines
4.5 KiB
Dart

import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:pyramid/core/app_dirs.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<String, Uint8List> _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<Uint8List?> 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<void> 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<File> _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<Directory> _initDiskDir() async {
final support = await appSupportDir();
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 = <File, FileStat>{};
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;
}
}