feat: предпросмотр расшифрованный
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user