Files
Client/lib/widget/avatar_disk_cache.dart
T

188 lines
5.8 KiB
Dart

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<Directory>? _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> _directory() {
return _dirFuture ??= _resolveDirectory();
}
Future<Directory> _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_<hex>_` 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<Uint8List?> 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<void> 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<void> 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<void> 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<void> _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.
}
}
}