246 lines
9.5 KiB
Dart
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
|
|
}
|
|
}
|
|
}
|