feat: предпросмотр расшифрованный

This commit is contained in:
Jganenokk
2026-07-29 20:17:25 +07:00
parent 0e51038051
commit 777515932e
4 changed files with 467 additions and 34 deletions
+3 -1
View File
@@ -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<EncryptedPhotoResult> 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);
}
+246
View File
@@ -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<String?> 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<String, ValueNotifier<EncryptedPhotoView?>> _entries =
LinkedHashMap();
final Map<String, Future<EncryptedPhotoView>> _inFlight = {};
final Queue<_AutoRequest> _queue = Queue();
final Set<String> _queued = {};
int _running = 0;
ValueListenable<EncryptedPhotoView?> listenableFor(String cacheName) =>
_entryFor(cacheName);
ValueNotifier<EncryptedPhotoView?> _entryFor(String cacheName) =>
_entries[cacheName] ??= ValueNotifier<EncryptedPhotoView?>(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<EncryptedPhotoView> 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<EncryptedPhotoView> _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<EncryptedPhotoView> _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<EncryptedPhotoView> _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<File?> _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);
}
}
}
@@ -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<String?> _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<void> _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<File?> _decryptIfNeeded(File local, String cacheName) async {
Future<File?> _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<void> _downloadFile(
+72
View File
@@ -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<DecryptedPhoto> createState() => _DecryptedPhotoState();
}
class _DecryptedPhotoState extends State<DecryptedPhoto> {
@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<EncryptedPhotoView?>(
valueListenable: EncryptedPhotoCache.instance.listenableFor(
widget.cacheName,
),
builder: (context, view, _) => widget.builder(view),
);
}
}