dba4b64df8
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>
140 lines
4.5 KiB
Dart
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;
|
|
}
|
|
}
|