add ticker page bloc and avatar disk cache
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user