add ticker page bloc and avatar disk cache

This commit is contained in:
2026-07-12 23:18:53 +02:00
parent 94794ff092
commit 9b5198c6db
18 changed files with 944 additions and 233 deletions
+187
View File
@@ -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.
}
}
}
+128 -25
View File
@@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:http/http.dart' as http;
@@ -10,6 +10,7 @@ import 'package:http/http.dart' as http;
import '../model/account_data.dart';
import '../model/endpoint_data.dart';
import '../push/push_avatar.dart';
import 'avatar_disk_cache.dart';
class UserAvatar extends StatefulWidget {
final String id;
@@ -84,10 +85,12 @@ void invalidateAvatarCache({String? id, bool? isGroup}) {
if (id == null) {
_resolvedAvatars.clear();
_pendingAvatars.clear();
unawaited(AvatarDiskCache.instance.clear());
} else if (isGroup == true) {
final url = avatarUrl(id: id, isGroup: true);
_resolvedAvatars.remove(url);
_pendingAvatars.remove(url);
unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: true));
// Keep the push-notification disk cache in sync — it serves the same
// room avatar to the FCM background isolate.
unawaited(PushAvatarStore.evict(id));
@@ -97,6 +100,7 @@ void invalidateAvatarCache({String? id, bool? isGroup}) {
final prefix = 'https://$host/avatar/$id/';
_resolvedAvatars.removeWhere((url, _) => url.startsWith(prefix));
_pendingAvatars.removeWhere((url, _) => url.startsWith(prefix));
unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: false));
}
_avatarCacheGeneration.value++;
}
@@ -170,34 +174,133 @@ class _UserAvatarState extends State<UserAvatar> {
_payload = cached.payload;
return;
}
_payload = null;
final pending = _pendingAvatars.putIfAbsent(url, () => _fetch(url));
pending.then((p) {
_writeAvatarCache(url, p);
_pendingAvatars.remove(url);
if (!mounted || _url() != url) return;
setState(() => _payload = p);
});
// Capture the subject once — later async steps must not read widget.* since
// the widget may have been recycled onto a different id by then.
final id = widget.id;
final isGroup = widget.isGroup;
final size = _resolvedRequestSize();
// Persistent disk cache: on a warm session (cache directory already known)
// this hits synchronously, so a cold app start paints the last-known
// picture on the first frame instead of a blank placeholder.
final diskBytes = AvatarDiskCache.instance.readSync(
id: id,
isGroup: isGroup,
size: size,
);
if (diskBytes != null) {
final payload = _payloadFromBytes(diskBytes);
_payload = payload;
_writeAvatarCache(url, payload);
} else {
_payload = null;
}
unawaited(_resolve(url, id, isGroup, size, haveBytes: _payload != null));
}
Future<_AvatarPayload?> _fetch(String url) async {
try {
final response = await http.get(
Uri.parse(url),
headers: {
'Authorization': AccountData().getBasicAuthHeader(),
'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml',
},
/// Fills the placeholder from disk (async path, for the first avatar of a
/// session), then always refreshes over the network so a changed server-side
/// picture replaces the cached one. Network work is deduped across every
/// widget showing the same avatar via [_pendingAvatars].
Future<void> _resolve(
String url,
String id,
bool isGroup,
int size, {
required bool haveBytes,
}) async {
if (!haveBytes) {
final diskBytes = await AvatarDiskCache.instance.read(
id: id,
isGroup: isGroup,
size: size,
);
if (response.statusCode != 200 || response.bodyBytes.isEmpty) return null;
final contentType = response.headers['content-type']?.toLowerCase() ?? '';
final bytes = response.bodyBytes;
final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes);
return _AvatarPayload(bytes, isSvg);
} catch (_) {
return null;
if (diskBytes != null && mounted && _url() == url && _payload == null) {
final payload = _payloadFromBytes(diskBytes);
_writeAvatarCache(url, payload);
setState(() => _payload = payload);
}
}
final pending = _pendingAvatars.putIfAbsent(url, () {
final future = _fetch(url);
future.whenComplete(() {
if (identical(_pendingAvatars[url], future)) _pendingAvatars.remove(url);
});
return future;
});
_AvatarPayload? fresh;
try {
fresh = await pending;
} on Object {
// Transient failure (offline, 5xx). Keep showing the cached picture; the
// next mount retries. Deliberately no null-cache so we don't mask it.
return;
}
_commit(url, id, isGroup, size, fresh);
if (!mounted || _url() != url) return;
if (fresh == null) {
// HTTP 404 — the avatar was removed server-side. Fall back to the icon.
if (_payload != null) setState(() => _payload = null);
} else if (!_sameBytes(_payload, fresh)) {
setState(() => _payload = fresh);
}
}
// Persists a resolved result to the in-memory and disk caches. Uses the
// captured subject (not widget.*) so a recycled widget can't misfile bytes.
void _commit(
String url,
String id,
bool isGroup,
int size,
_AvatarPayload? payload,
) {
_writeAvatarCache(url, payload);
if (payload != null) {
unawaited(
AvatarDiskCache.instance.write(
id: id,
isGroup: isGroup,
size: size,
bytes: payload.bytes,
),
);
} else {
unawaited(AvatarDiskCache.instance.evict(id: id, isGroup: isGroup));
}
}
static _AvatarPayload _payloadFromBytes(Uint8List bytes) =>
_AvatarPayload(bytes, _looksLikeSvg(bytes));
static bool _sameBytes(_AvatarPayload? a, _AvatarPayload? b) {
if (a == null || b == null) return a == b;
return listEquals(a.bytes, b.bytes);
}
/// Returns the avatar bytes, `null` for a definitive miss (HTTP 404 — no
/// avatar exists), or throws on a transient error (offline, non-200) so the
/// caller keeps the cached picture instead of blanking it.
Future<_AvatarPayload?> _fetch(String url) async {
final response = await http.get(
Uri.parse(url),
headers: {
'Authorization': AccountData().getBasicAuthHeader(),
'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml',
},
);
if (response.statusCode == 404) return null;
if (response.statusCode != 200 || response.bodyBytes.isEmpty) {
throw Exception('avatar fetch failed: HTTP ${response.statusCode}');
}
final contentType = response.headers['content-type']?.toLowerCase() ?? '';
final bytes = response.bodyBytes;
final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes);
return _AvatarPayload(bytes, isSvg);
}
static bool _looksLikeSvg(Uint8List bytes) {