Files
Client/lib/push/push_avatar.dart
T

246 lines
9.5 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import '../api/marianumcloud/nextcloud_ocs.dart';
/// Result of an avatar lookup at render time: [icon] when bytes are available
/// within the fetch budget, otherwise [late] carries the still-running fetch
/// so the caller can re-render once (silently) when it eventually delivers.
typedef AvatarIconLookup = ({Uint8List? icon, Future<Uint8List?>? late});
/// Masks avatar [bytes] into a circular PNG on a square canvas (center-cover
/// crop). Devices do NOT reliably mask Person icons themselves, so the round
/// shape is baked into the bitmap. Runs on `dart:ui`, which is available in
/// engine-backed background isolates (the FCM handler isolate) — no
/// BuildContext involved. Returns null when decoding fails; callers fall back
/// to the raw bytes.
Future<Uint8List?> maskAvatarCircular(Uint8List bytes) async {
try {
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
final image = frame.image;
final size = image.width < image.height ? image.width : image.height;
final src = ui.Rect.fromLTWH(
(image.width - size) / 2,
(image.height - size) / 2,
size.toDouble(),
size.toDouble(),
);
final dst = ui.Rect.fromLTWH(0, 0, size.toDouble(), size.toDouble());
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
canvas.clipPath(ui.Path()..addOval(dst));
canvas.drawImageRect(image, src, dst, ui.Paint()..isAntiAlias = true);
final masked = await recorder.endRecording().toImage(size, size);
final data = await masked.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
masked.dispose();
return data?.buffer.asUint8List();
} on Object catch (e) {
log('Push avatar: circular mask failed: $e');
return null;
}
}
/// Loads conversation avatars for Talk push notifications.
///
/// Uses the same Spreed endpoint as the in-app `UserAvatar` widget
/// (`room/{token}/avatar`, no query parameters — for 1:1 chats this is the
/// other person's picture), but with its own disk cache of PRE-MASKED round
/// PNGs: the widget cache is an in-memory LRU that is empty in the FCM
/// background isolate, and masking must not be recomputed per push. Every
/// failure path returns null so a missing avatar can never delay or drop a
/// notification beyond [fetchTimeout].
class PushAvatarStore {
/// Cached files older than this are treated as stale and pruned.
static const Duration maxAge = Duration(days: 14);
static const Duration _defaultFetchTimeout = Duration(seconds: 4);
// Versioned: `push_avatars` (raw, unmasked) was used before masking landed —
// reusing it would surface square icons from old cache entries.
static const _subDirectory = 'push_avatars_masked';
/// One shared fetch per token and isolate: the bounded render-time lookup
/// and the late re-render both await the SAME future, and concurrent pushes
/// don't stampede the endpoint.
static final Map<String, Future<Uint8List?>> _inflight = {};
final Future<Directory> Function() _cacheDirProvider;
/// Fetches the raw avatar response for a chat token. Injectable so tests
/// never touch the network or the account/endpoint singletons (the default
/// builds auth headers from them).
final Future<http.Response> Function(String chatToken) _fetch;
final Duration fetchTimeout;
PushAvatarStore({
Future<Directory> Function()? cacheDirProvider,
Future<http.Response> Function(String chatToken)? fetch,
this.fetchTimeout = _defaultFetchTimeout,
}) : _cacheDirProvider = cacheDirProvider ?? _defaultCacheDir,
_fetch = fetch ?? _defaultFetch;
static Future<Directory> _defaultCacheDir() async {
final base = await getApplicationCacheDirectory();
return Directory('${base.path}/$_subDirectory');
}
static Future<http.Response> _defaultFetch(String chatToken) => http.get(
NextcloudOcs.uri('apps/spreed/api/v1/room/$chatToken/avatar'),
headers: {
...NextcloudOcs.headers(),
'Accept': 'image/png,image/jpeg,image/webp',
},
);
/// File-safe cache name for a chat token. Tokens are normally URL-safe
/// already and used verbatim; anything else is base64url-encoded so exotic
/// ids can never escape the cache directory.
static String fileNameForToken(String token) =>
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(token)
? token
: base64Url.encode(utf8.encode(token)).replaceAll('=', '');
/// Whether a cache file written at [modified] is still fresh at [now].
static bool isFresh(DateTime modified, DateTime now) =>
now.difference(modified) <= maxAge;
/// Spreed answers with an SVG placeholder (initials/icon) when a chat has
/// no real picture — useless for a notification bitmap, so it is skipped.
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');
}
/// Returns the (round-masked) conversation avatar for [chatToken]. Order:
/// fresh disk cache → shared network fetch bounded by [fetchTimeout] →
/// stale disk cache. On timeout the fetch keeps running and is returned as
/// [AvatarIconLookup.late] so the caller can re-render once it delivers.
Future<AvatarIconLookup> roomAvatarIcon(String chatToken) async {
try {
final file = await _fileFor(chatToken);
final fresh = await _readCache(file, allowStale: false);
if (fresh != null) return (icon: fresh, late: null);
final fetch = _inflight.putIfAbsent(chatToken, () {
final future = _fetchProcessAndCache(chatToken, file);
future.whenComplete(() {
if (identical(_inflight[chatToken], future)) {
_inflight.remove(chatToken);
}
});
return future;
});
try {
final fetched = await fetch.timeout(fetchTimeout);
if (fetched != null) return (icon: fetched, late: null);
// Definitive miss (no picture / error) — an outdated icon beats none.
return (icon: await _readCache(file, allowStale: true), late: null);
} on TimeoutException {
// Fetch continues in the background; hand it to the caller for the
// one-time silent re-render.
return (icon: await _readCache(file, allowStale: true), late: fetch);
}
} on Object catch (e) {
log('Push avatar $chatToken: lookup failed: $e');
return (icon: null, late: null);
}
}
/// Drops the cached avatar for [chatToken] — called when the app knows the
/// picture changed (avatar upload/removal in chat settings).
static Future<void> evict(String chatToken) async {
try {
final dir = await _defaultCacheDir();
final file = File('${dir.path}/${fileNameForToken(chatToken)}');
if (file.existsSync()) await file.delete();
} on Object {
// best effort — the 14-day max age catches it eventually
}
}
Future<File> _fileFor(String chatToken) async {
final dir = await _cacheDirProvider();
await dir.create(recursive: true);
return File('${dir.path}/${fileNameForToken(chatToken)}');
}
Future<Uint8List?> _readCache(File file, {required bool allowStale}) async {
try {
if (!file.existsSync()) return null;
if (!allowStale && !isFresh(file.lastModifiedSync(), DateTime.now())) {
return null;
}
final bytes = await file.readAsBytes();
return bytes.isEmpty ? null : bytes;
} on Object {
return null;
}
}
/// Full pipeline: fetch (with outcome diagnostics) → circular mask (raw
/// fallback when masking fails) → write processed cache. Never throws.
Future<Uint8List?> _fetchProcessAndCache(String chatToken, File file) async {
try {
final raw = await _fetchAvatar(chatToken);
if (raw == null) return null;
final icon = await maskAvatarCircular(raw) ?? raw;
await file.writeAsBytes(icon, flush: true);
unawaited(_prune(file.parent));
return icon;
} on Object catch (e) {
log('Push avatar $chatToken: fetch/process failed: $e');
return null;
}
}
Future<Uint8List?> _fetchAvatar(String chatToken) async {
final response = await _fetch(chatToken);
final contentType = response.headers['content-type']?.toLowerCase() ?? '';
final bytes = response.bodyBytes;
final svgRejected =
response.statusCode == 200 &&
bytes.isNotEmpty &&
(contentType.contains('svg') || looksLikeSvg(bytes));
// Diagnostics: makes "no icon" cases attributable (status vs. svg
// placeholder vs. empty body) without a debugger on the device.
log(
'Push avatar $chatToken: HTTP ${response.statusCode} '
'type=$contentType bytes=${bytes.length} svgRejected=$svgRejected',
);
if (response.statusCode != 200 || bytes.isEmpty || svgRejected) return null;
return bytes;
}
/// Deletes cache files past [maxAge]. Fire-and-forget after a successful
/// write — the directory stays small (one file per recently active chat).
Future<void> _prune(Directory dir) async {
try {
final now = DateTime.now();
await for (final entry in dir.list()) {
if (entry is! File) continue;
if (!isFresh(entry.lastModifiedSync(), now)) {
await entry.delete();
}
}
} on Object {
// best effort
}
}
}