diff --git a/lib/core/crypto/encrypted_photo.dart b/lib/core/crypto/encrypted_photo.dart index 655d25a..aaa0c2c 100644 --- a/lib/core/crypto/encrypted_photo.dart +++ b/lib/core/crypto/encrypted_photo.dart @@ -9,6 +9,8 @@ import 'chat_crypto_service.dart'; const String kEncryptedPhotoExtension = '.png'; +String decryptedCacheName(String cacheName) => 'decrypted_$cacheName'; + class EncryptedPhotoResult { final File? file; final CryptoFailure? failure; @@ -79,7 +81,7 @@ Future openEncryptedPhoto({ required File encrypted, required String cacheName, }) async { - final target = await MediaCache.fileFor('decrypted_$cacheName'); + final target = await MediaCache.fileFor(decryptedCacheName(cacheName)); if (await target.exists() && await target.length() > 0) { return EncryptedPhotoResult.ok(target); } diff --git a/lib/core/crypto/encrypted_photo_cache.dart b/lib/core/crypto/encrypted_photo_cache.dart new file mode 100644 index 0000000..eb11f25 --- /dev/null +++ b/lib/core/crypto/encrypted_photo_cache.dart @@ -0,0 +1,246 @@ +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); + } + } +} diff --git a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart index b3c6ef4..965df51 100644 --- a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart @@ -12,13 +12,17 @@ import '../../../../core/utils/media_cache.dart'; import '../../../../core/utils/format.dart'; import '../../../../core/utils/haptics.dart'; import '../../../../core/crypto/chat_crypto_service.dart'; -import '../../../../core/crypto/encrypted_photo.dart'; +import '../../../../core/crypto/encrypted_photo_cache.dart'; import '../../../../models/attachment.dart'; import '../../custom_notification.dart'; +import '../../decrypted_photo.dart'; import '../../photo_viewer.dart'; import 'bubble_context.dart'; class FileBubble extends StatelessWidget { + static const double _previewWidth = 240; + static const double _previewHeight = 160; + final BubbleContext ctx; final FileAttachment file; final bool fill; @@ -41,6 +45,11 @@ class FileBubble extends StatelessWidget { final preview = file.preview; final previewUrl = preview?.baseUrl ?? preview?.previewData ?? ''; + final previewWidget = _preview( + cacheName: cacheName, + previewUrl: previewUrl, + encrypted: fileId != null && _isEncryptedImage(name), + ); final inner = Padding( padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), @@ -48,21 +57,7 @@ class FileBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - if (previewUrl.isNotEmpty) ...[ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: CachedNetworkImage( - imageUrl: previewUrl, - width: 240, - height: 160, - fit: BoxFit.cover, - memCacheWidth: 480, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (_, _, _) => const SizedBox.shrink(), - ), - ), - const SizedBox(height: 8), - ], + ?previewWidget, Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, @@ -185,6 +180,116 @@ class FileBubble extends StatelessWidget { static bool _isViewableImage(String name) => name.toLowerCase().endsWith('.png'); + bool _isEncryptedImage(String name) => + _isViewableImage(name) && + ChatCryptoService.instance.isEnabled( + ctx.message.accountId, + ctx.message.chatId, + ); + + Widget? _preview({ + required String cacheName, + required String previewUrl, + required bool encrypted, + }) { + if (encrypted) { + return DecryptedPhoto( + accountId: ctx.message.accountId, + chatId: ctx.message.chatId, + cacheName: cacheName, + size: file.size ?? 0, + urlLoader: _fileUrl, + builder: (view) => _encryptedPreview(view, previewUrl), + ); + } + if (previewUrl.isEmpty) return null; + return _networkPreview(previewUrl); + } + + Widget _encryptedPreview(EncryptedPhotoView? view, String previewUrl) { + switch (view?.status) { + case EncryptedPhotoStatus.decrypted: + return _framed( + Image.file( + view!.file!, + width: _previewWidth, + height: _previewHeight, + fit: BoxFit.cover, + cacheWidth: 480, + errorBuilder: (_, _, _) => _placeholder( + icon: Symbols.broken_image, + label: 'Файл повреждён', + ), + ), + ); + case EncryptedPhotoStatus.plain: + return previewUrl.isEmpty + ? const SizedBox.shrink() + : _networkPreview(previewUrl); + case EncryptedPhotoStatus.wrongKey: + return _placeholder(icon: Symbols.lock, label: 'Неверный ключ'); + case EncryptedPhotoStatus.locked: + return _placeholder( + icon: Symbols.lock, + label: 'Нажмите, чтобы открыть', + ); + case null: + return _placeholder(); + } + } + + Widget _networkPreview(String url) => _framed( + CachedNetworkImage( + imageUrl: url, + width: _previewWidth, + height: _previewHeight, + fit: BoxFit.cover, + memCacheWidth: 480, + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ); + + Widget _placeholder({IconData? icon, String? label}) => _framed( + Container( + width: _previewWidth, + height: _previewHeight, + alignment: Alignment.center, + color: ctx.isMe ? ctx.systemTint : ctx.cs.surfaceContainerHighest, + child: icon == null + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2, color: ctx.dim), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 26, color: ctx.dim), + if (label != null) ...[ + const SizedBox(height: 6), + Text(label, style: TextStyle(color: ctx.dim, fontSize: 12)), + ], + ], + ), + ), + ); + + Widget _framed(Widget child) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ClipRRect(borderRadius: BorderRadius.circular(10), child: child), + ); + + Future _fileUrl() { + final fileId = file.fileId; + if (fileId == null) return Future.value(null); + return messagesModule.getFileUrl( + messageId: ctx.message.id, + chatId: ctx.message.chatId, + fileId: fileId, + ); + } + Future _openInViewer( BuildContext context, String name, @@ -198,11 +303,7 @@ class FileBubble extends StatelessWidget { if (!wasCached) MediaDownloadProgress.set(cacheName, 0); File? local; try { - final url = await messagesModule.getFileUrl( - messageId: ctx.message.id, - chatId: ctx.message.chatId, - fileId: fileId, - ); + final url = await _fileUrl(); if (url != null && url.isNotEmpty) { local = await MediaCache.getOrDownload( cacheName, @@ -241,12 +342,9 @@ class FileBubble extends StatelessWidget { ); } catch (_) {} - final shown = await _decryptIfNeeded(local, cacheName); if (!context.mounted) return; - if (shown == null) { - showCustomNotification(context, 'Неверный ключ'); - return; - } + final shown = await _decryptIfNeeded(context, local, cacheName); + if (!context.mounted || shown == null) return; await Navigator.of(context).push( MaterialPageRoute( @@ -260,20 +358,35 @@ class FileBubble extends StatelessWidget { ); } - Future _decryptIfNeeded(File local, String cacheName) async { + Future _decryptIfNeeded( + BuildContext context, + File local, + String cacheName, + ) async { final accountId = ctx.message.accountId; final chatId = ctx.message.chatId; if (!ChatCryptoService.instance.isEnabled(accountId, chatId)) return local; - if (!await ChatCryptoService.instance.looksEncryptedImage(local.path)) { - return local; - } - final result = await openEncryptedPhoto( + + final view = await EncryptedPhotoCache.instance.resolve( accountId: accountId, chatId: chatId, - encrypted: local, cacheName: cacheName, + urlLoader: _fileUrl, ); - return result.file; + if (!context.mounted) return null; + + switch (view.status) { + case EncryptedPhotoStatus.decrypted: + return view.file; + case EncryptedPhotoStatus.plain: + return local; + case EncryptedPhotoStatus.wrongKey: + showCustomNotification(context, 'Неверный ключ'); + return null; + case EncryptedPhotoStatus.locked: + showCustomNotification(context, 'Не удалось расшифровать фото'); + return null; + } } Future _downloadFile( diff --git a/lib/frontend/widgets/decrypted_photo.dart b/lib/frontend/widgets/decrypted_photo.dart new file mode 100644 index 0000000..6e888c0 --- /dev/null +++ b/lib/frontend/widgets/decrypted_photo.dart @@ -0,0 +1,72 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../core/crypto/encrypted_photo_cache.dart'; +import '../../core/storage/chat_encryption_store.dart'; + +class DecryptedPhoto extends StatefulWidget { + final int accountId; + final int chatId; + final String cacheName; + final int size; + final EncryptedPhotoUrlLoader urlLoader; + final Widget Function(EncryptedPhotoView? view) builder; + + const DecryptedPhoto({ + super.key, + required this.accountId, + required this.chatId, + required this.cacheName, + required this.size, + required this.urlLoader, + required this.builder, + }); + + @override + State createState() => _DecryptedPhotoState(); +} + +class _DecryptedPhotoState extends State { + @override + void initState() { + super.initState(); + _request(); + ChatEncryptionStore.instance.revision.addListener(_onEncryptionChanged); + } + + @override + void dispose() { + ChatEncryptionStore.instance.revision.removeListener(_onEncryptionChanged); + super.dispose(); + } + + @override + void didUpdateWidget(DecryptedPhoto old) { + super.didUpdateWidget(old); + if (old.cacheName != widget.cacheName) _request(); + } + + void _onEncryptionChanged() => scheduleMicrotask(_request); + + void _request() { + if (!mounted) return; + EncryptedPhotoCache.instance.request( + accountId: widget.accountId, + chatId: widget.chatId, + cacheName: widget.cacheName, + urlLoader: widget.urlLoader, + size: widget.size, + ); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: EncryptedPhotoCache.instance.listenableFor( + widget.cacheName, + ), + builder: (context, view, _) => widget.builder(view), + ); + } +}