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.
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
-25
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user