import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:http/http.dart' as http; import '../model/account_data.dart'; import '../model/endpoint_data.dart'; import '../push/push_avatar.dart'; import 'a11y/a11y_labels.dart'; import 'avatar_disk_cache.dart'; class UserAvatar extends StatefulWidget { final String id; final bool isGroup; final int size; /// Screenreader-Beschreibung. Aufrufer, die den Namen kennen, sollten ihn /// mitgeben; sonst greift ein generisches „Profilbild"/„Gruppenbild". final String? semanticLabel; /// Server-side pixel size requested for user avatars. `null` lets the /// widget pick `(size * 4).clamp(64, 1024)` — enough headroom for typical /// device pixel ratios. Group avatars ignore this (Spreed serves one /// fixed-size image per token). final int? requestSize; const UserAvatar({ required this.id, this.isGroup = false, this.size = 20, this.requestSize, this.semanticLabel, super.key, }); @override State createState() => _UserAvatarState(); } class _AvatarPayload { final Uint8List bytes; final bool isSvg; _AvatarPayload(this.bytes, this.isSvg); } class _AvatarCacheEntry { final _AvatarPayload? payload; final DateTime fetchedAt; _AvatarCacheEntry(this.payload, this.fetchedAt); } // LRU via LinkedHashMap insertion order + remove-on-hit. TTL so // server-side avatar updates become visible within a session. const int _kAvatarCacheMax = 256; const Duration _kAvatarCacheTtl = Duration(minutes: 30); // Pending map dedups concurrent mounts onto a single HTTP call. final LinkedHashMap _resolvedAvatars = LinkedHashMap(); final Map> _pendingAvatars = {}; // Bumped by invalidateAvatarCache so *already mounted* avatars re-resolve. // Clearing the cache map alone only affects future mounts — a UserAvatar // elsewhere on screen (chat list, chat header) holds its bytes in State and // would keep showing the stale image until rebuilt. Each state listens here // and re-attaches: invalidated urls miss the cache and re-fetch, the rest // hit the cache and cost nothing. final ValueNotifier _avatarCacheGeneration = ValueNotifier(0); String avatarUrl({required String id, required bool isGroup, int size = 512}) { final host = EndpointData().nextcloud().full(); if (isGroup) { return 'https://$host/ocs/v2.php/apps/spreed/api/v1/room/$id/avatar'; } return 'https://$host/avatar/$id/$size'; } /// Drops cached avatar bytes so the next mount re-fetches from the server. /// Call after the app uploaded or removed an avatar — without this the /// 30-min TTL would mask the change for the rest of the session. /// /// With [id]+[isGroup], invalidates every cached size for that subject /// (user avatars cache per-size URL). Without arguments, clears everything. /// Pending fetches are also discarded so a stale in-flight response can't /// repopulate the cache. 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)); } else { // User avatars include the rendered size in the URL — drop every variant. final host = EndpointData().nextcloud().full(); 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++; } _AvatarCacheEntry? _readAvatarCache(String url) { final entry = _resolvedAvatars.remove(url); if (entry == null) return null; if (DateTime.now().difference(entry.fetchedAt) > _kAvatarCacheTtl) { return null; } // Re-insert at the tail so it counts as most-recently-used. _resolvedAvatars[url] = entry; return entry; } void _writeAvatarCache(String url, _AvatarPayload? payload) { _resolvedAvatars.remove(url); _resolvedAvatars[url] = _AvatarCacheEntry(payload, DateTime.now()); while (_resolvedAvatars.length > _kAvatarCacheMax) { _resolvedAvatars.remove(_resolvedAvatars.keys.first); } } class _UserAvatarState extends State { _AvatarPayload? _payload; @override void initState() { super.initState(); _attach(); _avatarCacheGeneration.addListener(_onCacheInvalidated); } @override void dispose() { _avatarCacheGeneration.removeListener(_onCacheInvalidated); super.dispose(); } // Re-resolve when the cache generation changes. Cache hit → no network and // the visible bytes stay; cache miss (our url was invalidated) → re-fetch. void _onCacheInvalidated() { if (!mounted) return; setState(_attach); } @override void didUpdateWidget(UserAvatar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.id != widget.id || oldWidget.isGroup != widget.isGroup || oldWidget.size != widget.size || oldWidget.requestSize != widget.requestSize) { _attach(); } } int _resolvedRequestSize() => widget.requestSize ?? (widget.size * 4).clamp(64, 1024); String _url() => avatarUrl( id: widget.id, isGroup: widget.isGroup, size: _resolvedRequestSize(), ); void _attach() { final url = _url(); final cached = _readAvatarCache(url); if (cached != null) { _payload = cached.payload; return; } // 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)); } /// 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 _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 (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); // Cleanup hangs off an error-neutralised copy: whenComplete on `future` // itself returns a second future that forwards the error unawaited. unawaited( future.then((_) {}, onError: (_) {}).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) { final head = utf8 .decode( bytes.sublist(0, bytes.length < 256 ? bytes.length : 256), allowMalformed: true, ) .trimLeft(); return head.startsWith('