import 'dart:async'; import 'dart:collection'; import 'dart:io'; import 'package:flutter/foundation.dart'; import '../storage/chat_encryption_store.dart'; import '../utils/download_progress.dart'; import '../utils/logger.dart'; import '../utils/media_cache.dart'; import 'chat_crypto_service.dart'; import 'encrypted_photo.dart'; typedef EncryptedPhotoUrlLoader = Future Function(); enum EncryptedPhotoStatus { plain, decrypted, wrongKey, locked } @immutable class EncryptedPhotoView { final File? file; final EncryptedPhotoStatus status; const EncryptedPhotoView.plain() : file = null, status = EncryptedPhotoStatus.plain; const EncryptedPhotoView.decrypted(File this.file) : status = EncryptedPhotoStatus.decrypted; const EncryptedPhotoView.wrongKey() : file = null, status = EncryptedPhotoStatus.wrongKey; const EncryptedPhotoView.locked() : file = null, status = EncryptedPhotoStatus.locked; bool get isDecrypted => status == EncryptedPhotoStatus.decrypted; } class _AutoRequest { final int accountId; final int chatId; final String cacheName; final EncryptedPhotoUrlLoader urlLoader; final int size; const _AutoRequest({ required this.accountId, required this.chatId, required this.cacheName, required this.urlLoader, required this.size, }); } class EncryptedPhotoCache { EncryptedPhotoCache._() { ChatEncryptionStore.instance.revision.addListener(clear); } static final EncryptedPhotoCache instance = EncryptedPhotoCache._(); static const int _maxEntries = 200; static const int _maxAutoBytes = 32 * 1024 * 1024; static const int _maxConcurrentAuto = 3; final LinkedHashMap> _entries = LinkedHashMap(); final Map> _inFlight = {}; final Queue<_AutoRequest> _queue = Queue(); final Set _queued = {}; int _running = 0; ValueListenable listenableFor(String cacheName) => _entryFor(cacheName); ValueNotifier _entryFor(String cacheName) => _entries[cacheName] ??= ValueNotifier(null); void _evictStale(String keep) { while (_entries.length > _maxEntries) { final oldest = _entries.keys.first; if (oldest == keep) break; _entries.remove(oldest); } } void request({ required int accountId, required int chatId, required String cacheName, required EncryptedPhotoUrlLoader urlLoader, required int size, }) { if (!ChatCryptoService.instance.isEnabled(accountId, chatId)) return; if (_entryFor(cacheName).value != null) return; if (_inFlight.containsKey(cacheName)) return; if (!_queued.add(cacheName)) return; _evictStale(cacheName); _queue.add( _AutoRequest( accountId: accountId, chatId: chatId, cacheName: cacheName, urlLoader: urlLoader, size: size, ), ); _pump(); } Future resolve({ required int accountId, required int chatId, required String cacheName, required EncryptedPhotoUrlLoader urlLoader, }) { final known = _entryFor(cacheName).value; if (known != null && known.status != EncryptedPhotoStatus.locked) { return Future.value(known); } _dequeue(cacheName); return _start(accountId, chatId, cacheName, urlLoader, null); } void clear() { _queue.clear(); _queued.clear(); for (final entry in _entries.values) { entry.value = null; } } void _dequeue(String cacheName) { if (!_queued.remove(cacheName)) return; _queue.removeWhere((request) => request.cacheName == cacheName); } void _pump() { while (_running < _maxConcurrentAuto && _queue.isNotEmpty) { final next = _queue.removeLast(); _queued.remove(next.cacheName); _running++; final done = _start( next.accountId, next.chatId, next.cacheName, next.urlLoader, next.size, ); unawaited( done.whenComplete(() { _running--; _pump(); }), ); } } Future _start( int accountId, int chatId, String cacheName, EncryptedPhotoUrlLoader urlLoader, int? autoSize, ) { final running = _inFlight[cacheName]; if (running != null) return running; final future = _resolve(accountId, chatId, cacheName, urlLoader, autoSize); _inFlight[cacheName] = future; unawaited(future.whenComplete(() => _inFlight.remove(cacheName))); return future; } Future _resolve( int accountId, int chatId, String cacheName, EncryptedPhotoUrlLoader urlLoader, int? autoSize, ) async { EncryptedPhotoView view; try { view = await _decrypt(accountId, chatId, cacheName, urlLoader, autoSize); } catch (e) { logger.w('encrypted preview $cacheName: $e'); view = const EncryptedPhotoView.locked(); } _entryFor(cacheName).value = view; return view; } Future _decrypt( int accountId, int chatId, String cacheName, EncryptedPhotoUrlLoader urlLoader, int? autoSize, ) async { final ready = await MediaCache.existing(decryptedCacheName(cacheName)); if (ready != null) return EncryptedPhotoView.decrypted(ready); var encrypted = await MediaCache.existing(cacheName); if (encrypted == null) { if (autoSize != null && autoSize > _maxAutoBytes) { return const EncryptedPhotoView.locked(); } encrypted = await _download(cacheName, urlLoader); } if (encrypted == null) return const EncryptedPhotoView.locked(); if (!await ChatCryptoService.instance.looksEncryptedImage(encrypted.path)) { return const EncryptedPhotoView.plain(); } final result = await openEncryptedPhoto( accountId: accountId, chatId: chatId, encrypted: encrypted, cacheName: cacheName, ); if (result.isOk) return EncryptedPhotoView.decrypted(result.file!); return result.failure == CryptoFailure.unavailable ? const EncryptedPhotoView.locked() : const EncryptedPhotoView.wrongKey(); } Future _download( String cacheName, EncryptedPhotoUrlLoader urlLoader, ) async { final url = await urlLoader(); if (url == null || url.isEmpty) return null; MediaDownloadProgress.set(cacheName, 0); try { return await MediaCache.getOrDownload( cacheName, url, onProgress: (p) => MediaDownloadProgress.set(cacheName, p), ); } finally { MediaDownloadProgress.set(cacheName, null); } } }