import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:path_provider/path_provider.dart'; /// Persistent disk cache for the in-app [UserAvatar] widget. /// /// The widget keeps a session-scoped in-memory LRU; this store survives app /// restarts so a cold start paints the last-known picture on the first frame /// instead of a blank placeholder while the network request is in flight. /// Bytes are stored raw (PNG/JPEG/WEBP/SVG) — the widget re-detects SVG from /// the bytes on read, so no content-type sidecar is needed. /// /// Distinct from [PushAvatarStore], which caches PRE-MASKED round PNGs for the /// FCM background isolate and only handles room avatars. class AvatarDiskCache { AvatarDiskCache._(); static final AvatarDiskCache instance = AvatarDiskCache._(); static const _subDirectory = 'avatar_cache'; /// Files older than this are pruned. Only bounds disk for subjects that are /// no longer seen — freshness within a session is handled by the widget's /// background refresh, which always re-fetches over the network. static const Duration maxAge = Duration(days: 30); // Memoized so the async directory lookup runs once, and its resolved path is // exposed for the synchronous read path (zero-flash on warm sessions). Future? _dirFuture; static String? _dirPath; // Prune runs once per session after the first successful write, so a // cold-start burst of avatar fetches doesn't re-list the directory per file. bool _pruned = false; /// Kicks off directory resolution so [readSync] can hit on the very first /// avatar of a session. Fire-and-forget from app start; safe to call twice. void warmUp() => unawaited(_directory()); Future _directory() { return _dirFuture ??= _resolveDirectory(); } Future _resolveDirectory() async { final base = await getApplicationCacheDirectory(); final dir = Directory('${base.path}/$_subDirectory'); await dir.create(recursive: true); _dirPath = dir.path; return dir; } /// File-safe, prefix-evictable name. The subject id is hex-encoded so it can /// only contain `[0-9a-f]`, which keeps the `_` separator unambiguous: the /// user prefix `u__` never matches a longer id's file. static String fileName({ required String id, required bool isGroup, required int size, }) { final hex = _hex(id); // Group avatars are served at one fixed size (no size in the URL). return isGroup ? 'g_$hex' : 'u_${hex}_$size'; } static String _hex(String value) { final buffer = StringBuffer(); for (final b in utf8.encode(value)) { buffer.write(b.toRadixString(16).padLeft(2, '0')); } return buffer.toString(); } /// Synchronous read for warm sessions (cache directory already resolved). /// Returns null when the directory isn't known yet — the caller falls back /// to [read]. Returns null on any error so a corrupt file never throws into /// a build. Uint8List? readSync({ required String id, required bool isGroup, required int size, }) { final path = _dirPath; if (path == null) return null; try { final file = File( '$path/${fileName(id: id, isGroup: isGroup, size: size)}', ); if (!file.existsSync()) return null; final bytes = file.readAsBytesSync(); return bytes.isEmpty ? null : bytes; } on Object { return null; } } Future read({ required String id, required bool isGroup, required int size, }) async { try { final dir = await _directory(); final file = File( '${dir.path}/${fileName(id: id, isGroup: isGroup, size: size)}', ); if (!file.existsSync()) return null; final bytes = await file.readAsBytes(); return bytes.isEmpty ? null : bytes; } on Object { return null; } } Future write({ required String id, required bool isGroup, required int size, required Uint8List bytes, }) async { try { final dir = await _directory(); final file = File( '${dir.path}/${fileName(id: id, isGroup: isGroup, size: size)}', ); await file.writeAsBytes(bytes, flush: true); if (!_pruned) { _pruned = true; unawaited(_prune(dir)); } } on Object { // Best effort — a failed write just means the next launch re-fetches. } } /// Drops every cached size for a user (or the single file for a group). /// Called from `invalidateAvatarCache` after an upload/removal or a 404. Future evict({required String id, required bool isGroup}) async { try { final dir = await _directory(); if (isGroup) { final file = File('${dir.path}/${fileName(id: id, isGroup: true, size: 0)}'); if (file.existsSync()) await file.delete(); return; } final prefix = 'u_${_hex(id)}_'; await for (final entry in dir.list()) { if (entry is! File) continue; if (entry.uri.pathSegments.last.startsWith(prefix)) { await entry.delete(); } } } on Object { // Best effort — the 30-day max age catches stragglers. } } /// Wipes the whole cache — used by the argument-less `invalidateAvatarCache` /// (e.g. on logout). Future clear() async { try { final dir = await _directory(); if (dir.existsSync()) { await for (final entry in dir.list()) { if (entry is File) await entry.delete(); } } } on Object { // Best effort. } } Future _prune(Directory dir) async { try { final now = DateTime.now(); await for (final entry in dir.list()) { if (entry is! File) continue; if (now.difference(entry.lastModifiedSync()) > maxAge) { await entry.delete(); } } } on Object { // Best effort. } } }