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? 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 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> _inflight = {}; final Future 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 Function(String chatToken) _fetch; final Duration fetchTimeout; PushAvatarStore({ Future Function()? cacheDirProvider, Future Function(String chatToken)? fetch, this.fetchTimeout = _defaultFetchTimeout, }) : _cacheDirProvider = cacheDirProvider ?? _defaultCacheDir, _fetch = fetch ?? _defaultFetch; static Future _defaultCacheDir() async { final base = await getApplicationCacheDirectory(); return Directory('${base.path}/$_subDirectory'); } static Future _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(' 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 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 _fileFor(String chatToken) async { final dir = await _cacheDirProvider(); await dir.create(recursive: true); return File('${dir.path}/${fileNameForToken(chatToken)}'); } Future _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 _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 _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 _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 } } }