Final commit.
This commit is contained in:
162
lib/data/cache/attachment_cache.dart
vendored
Normal file
162
lib/data/cache/attachment_cache.dart
vendored
Normal file
@ -0,0 +1,162 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user