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? _dirFuture; Future _dir() => _dirFuture ??= _resolveDir(); Future _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({ 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 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 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 retainOnly(Set 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 _deleteQuietly(File f) async { try { await f.delete(); } catch (_) { // ignore } } }