188 lines
5.8 KiB
Dart
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.
|
|
}
|
|
}
|
|
}
|