multiple runtime bugfixes for 1.5.5+65

This commit is contained in:
2026-08-28 20:42:30 +02:00
parent 65300614b1
commit 71839d0848
18 changed files with 344 additions and 159 deletions
+126 -82
View File
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:http/http.dart' as http;
import '../api/http_errors.dart';
import '../model/account_data.dart';
import '../model/endpoint_data.dart';
import '../push/push_avatar.dart';
@@ -41,14 +42,17 @@ class UserAvatar extends StatefulWidget {
State<UserAvatar> createState() => _UserAvatarState();
}
class _AvatarPayload {
/// Raw avatar bytes plus the one bit callers need to render them: Nextcloud
/// and Spreed answer with SVG for generated (never uploaded) avatars, which no
/// `ImageProvider` can decode.
class AvatarPayload {
final Uint8List bytes;
final bool isSvg;
_AvatarPayload(this.bytes, this.isSvg);
AvatarPayload(this.bytes, this.isSvg);
}
class _AvatarCacheEntry {
final _AvatarPayload? payload;
final AvatarPayload? payload;
final DateTime fetchedAt;
_AvatarCacheEntry(this.payload, this.fetchedAt);
}
@@ -61,7 +65,7 @@ const Duration _kAvatarCacheTtl = Duration(minutes: 30);
// Pending map dedups concurrent mounts onto a single HTTP call.
final LinkedHashMap<String, _AvatarCacheEntry> _resolvedAvatars =
LinkedHashMap<String, _AvatarCacheEntry>();
final Map<String, Future<_AvatarPayload?>> _pendingAvatars = {};
final Map<String, Future<AvatarPayload?>> _pendingAvatars = {};
// Bumped by invalidateAvatarCache so *already mounted* avatars re-resolve.
// Clearing the cache map alone only affects future mounts — a UserAvatar
@@ -79,6 +83,118 @@ String avatarUrl({required String id, required bool isGroup, int size = 512}) {
return 'https://$host/avatar/$id/$size';
}
/// Resolves an avatar through the shared caches: in-memory first, then disk,
/// then the network — the fetch being deduped across every concurrent caller
/// and written back to both caches.
///
/// Returns `null` when the server has no avatar (HTTP 404) and throws on a
/// transient failure, so callers can keep whatever they already show.
Future<AvatarPayload?> loadAvatarPayload({
required String id,
required bool isGroup,
int size = 512,
}) async {
final url = avatarUrl(id: id, isGroup: isGroup, size: size);
final cached = _readAvatarCache(url);
if (cached != null) return cached.payload;
final diskBytes = await AvatarDiskCache.instance.read(
id: id,
isGroup: isGroup,
size: size,
);
if (diskBytes != null) {
final payload = _payloadFromBytes(diskBytes);
_writeAvatarCache(url, payload);
return payload;
}
final payload = await _fetchDeduped(url);
_commitAvatar(url, id: id, isGroup: isGroup, size: size, payload: payload);
return payload;
}
/// Shares one in-flight request per URL across all callers.
Future<AvatarPayload?> _fetchDeduped(String url) =>
_pendingAvatars.putIfAbsent(url, () {
final future = _fetchAvatarPayload(url);
// Cleanup hangs off an error-neutralised copy: whenComplete on `future`
// itself returns a second future that forwards the error unawaited.
unawaited(
future.then<void>((_) {}, onError: (_) {}).whenComplete(() {
if (identical(_pendingAvatars[url], future)) {
_pendingAvatars.remove(url);
}
}),
);
return future;
});
/// Persists a resolved result to the in-memory and disk caches.
void _commitAvatar(
String url, {
required String id,
required bool isGroup,
required int size,
required 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));
}
}
/// 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?> _fetchAvatarPayload(String url) async {
final response = await sendGuarded(
'Avatar $url',
() => http.get(
Uri.parse(url),
headers: {
...AccountData().authHeaders(),
'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml',
},
),
);
if (response == null) return null;
if (response.statusCode == 404) return null;
if (response.statusCode != 200 || response.bodyBytes.isEmpty) {
throwForStatus(
response.statusCode,
httpErrorDetail('Avatar $url', response.body, response.statusCode),
);
}
final contentType = response.headers['content-type']?.toLowerCase() ?? '';
final bytes = response.bodyBytes;
final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes);
return AvatarPayload(bytes, isSvg);
}
AvatarPayload _payloadFromBytes(Uint8List bytes) =>
AvatarPayload(bytes, _looksLikeSvg(bytes));
bool _looksLikeSvg(Uint8List bytes) {
final head = utf8
.decode(
bytes.sublist(0, bytes.length < 256 ? bytes.length : 256),
allowMalformed: true,
)
.trimLeft();
return head.startsWith('<?xml') || head.startsWith('<svg');
}
/// 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.
@@ -122,7 +238,7 @@ _AvatarCacheEntry? _readAvatarCache(String url) {
return entry;
}
void _writeAvatarCache(String url, _AvatarPayload? payload) {
void _writeAvatarCache(String url, AvatarPayload? payload) {
_resolvedAvatars.remove(url);
_resolvedAvatars[url] = _AvatarCacheEntry(payload, DateTime.now());
while (_resolvedAvatars.length > _kAvatarCacheMax) {
@@ -131,7 +247,7 @@ void _writeAvatarCache(String url, _AvatarPayload? payload) {
}
class _UserAvatarState extends State<UserAvatar> {
_AvatarPayload? _payload;
AvatarPayload? _payload;
@override
void initState() {
@@ -228,30 +344,16 @@ class _UserAvatarState extends State<UserAvatar> {
}
}
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<void>((_) {}, onError: (_) {}).whenComplete(() {
if (identical(_pendingAvatars[url], future)) {
_pendingAvatars.remove(url);
}
}),
);
return future;
});
_AvatarPayload? fresh;
AvatarPayload? fresh;
try {
fresh = await pending;
fresh = await _fetchDeduped(url);
} 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);
_commitAvatar(url, id: id, isGroup: isGroup, size: size, payload: fresh);
if (!mounted || _url() != url) return;
if (fresh == null) {
// HTTP 404 — the avatar was removed server-side. Fall back to the icon.
@@ -261,69 +363,11 @@ class _UserAvatarState extends State<UserAvatar> {
}
}
// 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) {
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('<?xml') || head.startsWith('<svg');
}
@override
Widget build(BuildContext context) {