163 lines
4.9 KiB
Dart
163 lines
4.9 KiB
Dart
import 'dart:io';
|
||
import 'dart:typed_data';
|
||
|
||
import 'package:path_provider/path_provider.dart';
|
||
|
||
/// Persistenter Datei-Cache für heruntergeladene Attachment-Vorschauen.
|
||
///
|
||
/// **Warum überhaupt ein Cache:** Vorschaubilder werden über
|
||
/// `GET /attachments/{id}` aus DOCUframe gerendert — das kostet Zeit und
|
||
/// Bandbreite und funktioniert offline gar nicht. Einmal geholte Varianten
|
||
/// landen deshalb auf der Platte und werden danach lokal bedient.
|
||
///
|
||
/// **Warum keine Content-Revalidierung:** Attachments sind unveränderlich.
|
||
/// Ein hochgeladenes Bild (DOCUframe-Objekt) ändert seinen Inhalt nie. Eine
|
||
/// einmal gecachte Variante ist daher dauerhaft gültig — kein ETag, kein
|
||
/// If-None-Match, kein HEAD nötig. Das Einzige, was den Cache betrifft, ist
|
||
/// das **Löschen** eines Attachments; dafür gibt es [retainOnly], das den
|
||
/// Cache auf die Menge noch gültiger Attachment-IDs eindampft.
|
||
///
|
||
/// **Datei-Layout:** Pro Attachment können mehrere *Varianten* liegen
|
||
/// (Thumbnail 600×600, Vollbild 2048×2048, …). Der Dateiname kodiert ID und
|
||
/// Render-Parameter:
|
||
///
|
||
/// `{attachmentId}__{w}x{h}_q{q}_{ext}`
|
||
///
|
||
/// Die ID steht vorne und ist als UUID frei von `__`, sodass [retainOnly] sie
|
||
/// zuverlässig wieder herausschneiden kann.
|
||
///
|
||
/// Der Cache ist durchweg **best-effort**: Lese-/Schreib-/Lösch-Fehler werden
|
||
/// geschluckt und führen höchstens zu einem erneuten Download, nie zu einem
|
||
/// Crash.
|
||
class AttachmentCache {
|
||
AttachmentCache();
|
||
|
||
static const _subdir = 'attachment_previews';
|
||
static const _separator = '__';
|
||
|
||
/// Einmal aufgelöstes Verzeichnis — `getApplicationCacheDirectory` nicht
|
||
/// bei jedem Zugriff neu abfragen.
|
||
Future<Directory>? _dirFuture;
|
||
|
||
Future<Directory> _dir() => _dirFuture ??= _resolveDir();
|
||
|
||
Future<Directory> _resolveDir() async {
|
||
final base = await getApplicationCacheDirectory();
|
||
final dir = Directory('${base.path}/$_subdir');
|
||
if (!await dir.exists()) {
|
||
await dir.create(recursive: true);
|
||
}
|
||
return dir;
|
||
}
|
||
|
||
String _fileName({
|
||
required String attachmentId,
|
||
required int w,
|
||
required int h,
|
||
required int q,
|
||
required String ext,
|
||
}) =>
|
||
'$attachmentId$_separator${w}x${h}_q${q}_$ext';
|
||
|
||
Future<File> _file({
|
||
required String attachmentId,
|
||
required int w,
|
||
required int h,
|
||
required int q,
|
||
required String ext,
|
||
}) async {
|
||
final dir = await _dir();
|
||
return File(
|
||
'${dir.path}/${_fileName(attachmentId: attachmentId, w: w, h: h, q: q, ext: ext)}',
|
||
);
|
||
}
|
||
|
||
/// Liest eine gecachte Variante. `null`, wenn nichts da ist oder das Lesen
|
||
/// scheitert — der Aufrufer lädt dann frisch.
|
||
Future<Uint8List?> read({
|
||
required String attachmentId,
|
||
required int w,
|
||
required int h,
|
||
required int q,
|
||
required String ext,
|
||
}) async {
|
||
try {
|
||
final f = await _file(
|
||
attachmentId: attachmentId,
|
||
w: w,
|
||
h: h,
|
||
q: q,
|
||
ext: ext,
|
||
);
|
||
if (!await f.exists()) return null;
|
||
final bytes = await f.readAsBytes();
|
||
return bytes.isEmpty ? null : bytes;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// Schreibt eine Variante. Atomar via temp-Datei + rename, damit ein
|
||
/// paralleler Read nie ein halb geschriebenes File sieht. Leere Bytes
|
||
/// werden ignoriert (kaputter Download soll keinen leeren Cache-Eintrag
|
||
/// hinterlassen).
|
||
Future<void> write({
|
||
required String attachmentId,
|
||
required int w,
|
||
required int h,
|
||
required int q,
|
||
required String ext,
|
||
required Uint8List bytes,
|
||
}) async {
|
||
if (bytes.isEmpty) return;
|
||
try {
|
||
final f = await _file(
|
||
attachmentId: attachmentId,
|
||
w: w,
|
||
h: h,
|
||
q: q,
|
||
ext: ext,
|
||
);
|
||
final tmp = File('${f.path}.tmp');
|
||
await tmp.writeAsBytes(bytes, flush: true);
|
||
await tmp.rename(f.path);
|
||
} catch (_) {
|
||
// best-effort
|
||
}
|
||
}
|
||
|
||
/// Entfernt alle gecachten Dateien, deren Attachment-ID **nicht** in
|
||
/// [validAttachmentIds] vorkommt. So verschwinden die Vorschauen gelöschter
|
||
/// Foto-Notizen beim nächsten Tour-Load aus dem Cache. Verwaiste
|
||
/// temp-Dateien (abgebrochene Writes) werden immer mit entfernt.
|
||
Future<void> retainOnly(Set<String> validAttachmentIds) async {
|
||
try {
|
||
final dir = await _dir();
|
||
if (!await dir.exists()) return;
|
||
await for (final entity in dir.list()) {
|
||
if (entity is! File) continue;
|
||
final name = entity.uri.pathSegments.last;
|
||
if (name.endsWith('.tmp')) {
|
||
await _deleteQuietly(entity);
|
||
continue;
|
||
}
|
||
final sepIdx = name.indexOf(_separator);
|
||
final id = sepIdx == -1 ? name : name.substring(0, sepIdx);
|
||
if (!validAttachmentIds.contains(id)) {
|
||
await _deleteQuietly(entity);
|
||
}
|
||
}
|
||
} catch (_) {
|
||
// best-effort — Pruning darf nie den Tour-Load stören
|
||
}
|
||
}
|
||
|
||
Future<void> _deleteQuietly(File f) async {
|
||
try {
|
||
await f.delete();
|
||
} catch (_) {
|
||
// ignore
|
||
}
|
||
}
|
||
}
|