feat: работа с шифрованным текстом/фото

This commit is contained in:
Jganenokk
2026-07-26 16:23:05 +07:00
parent ba1f63ad05
commit ac951ea80a
90 changed files with 7248 additions and 64 deletions
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -8,8 +10,11 @@ import '../../../../core/utils/file_download.dart';
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 '../../../../models/attachment.dart';
import '../../custom_notification.dart';
import '../../photo_viewer.dart';
import 'bubble_context.dart';
class FileBubble extends StatelessWidget {
@@ -167,7 +172,86 @@ class FileBubble extends StatelessWidget {
],
),
);
return fill ? inner : IntrinsicWidth(child: inner);
final body = fill ? inner : IntrinsicWidth(child: inner);
if (!_isViewableImage(name)) return body;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openInViewer(ctx.context, name, cacheName),
child: body,
);
}
static bool _isViewableImage(String name) =>
name.toLowerCase().endsWith('.png');
Future<void> _openInViewer(
BuildContext context,
String name,
String cacheName,
) async {
final fileId = file.fileId;
if (fileId == null) return;
Haptics.tap();
final wasCached = (await MediaCache.existing(cacheName)) != null;
if (!wasCached) MediaDownloadProgress.set(cacheName, 0);
File? local;
try {
final url = await messagesModule.getFileUrl(
messageId: ctx.message.id,
chatId: ctx.message.chatId,
fileId: fileId,
);
if (url != null && url.isNotEmpty) {
local = await MediaCache.getOrDownload(
cacheName,
url,
onProgress: (p) => MediaDownloadProgress.set(cacheName, p),
);
}
} finally {
if (!wasCached) MediaDownloadProgress.set(cacheName, null);
}
if (!context.mounted) return;
if (local == null) {
showCustomNotification(context, 'Не удалось загрузить файл');
return;
}
final shown = await _decryptIfNeeded(local, cacheName);
if (!context.mounted) return;
if (shown == null) {
showCustomNotification(context, 'Неверный ключ');
return;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PhotoViewerScreen(
photos: [PhotoAttachment(localPath: shown.path)],
chatId: ctx.message.chatId,
message: ctx.message,
isFile: true,
),
),
);
}
Future<File?> _decryptIfNeeded(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(
accountId: accountId,
chatId: chatId,
encrypted: local,
cacheName: cacheName,
);
return result.file;
}
Future<void> _downloadFile(
@@ -194,8 +278,10 @@ class FileBubble extends StatelessWidget {
fileId: fileId,
),
onProgress: (p) => MediaDownloadProgress.set(cacheName, p),
onReady: () {
if (!cached) MediaDownloadProgress.set(cacheName, null);
},
);
if (!cached) MediaDownloadProgress.set(cacheName, null);
if (!context.mounted) return;
if (!result.ok) {
showCustomNotification(
@@ -922,8 +922,8 @@ class _FileRow extends StatelessWidget {
fileId: fileId,
),
onProgress: (p) => MediaDownloadProgress.set(cacheName, p),
onReady: () => MediaDownloadProgress.set(cacheName, null),
);
MediaDownloadProgress.set(cacheName, null);
if (!context.mounted) return;
if (!result.ok) {
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../../core/crypto/message_decryption_cache.dart';
class DecryptedContent extends StatefulWidget {
final int accountId;
final int chatId;
final String messageId;
final String cipherText;
final Widget Function(MessageDecryption? decryption) builder;
const DecryptedContent({
super.key,
required this.accountId,
required this.chatId,
required this.messageId,
required this.cipherText,
required this.builder,
});
@override
State<DecryptedContent> createState() => _DecryptedContentState();
}
class _DecryptedContentState extends State<DecryptedContent> {
@override
void initState() {
super.initState();
_request();
}
@override
void didUpdateWidget(DecryptedContent old) {
super.didUpdateWidget(old);
if (old.messageId != widget.messageId ||
old.cipherText != widget.cipherText) {
_request();
}
}
void _request() {
MessageDecryptionCache.instance.request(
accountId: widget.accountId,
chatId: widget.chatId,
messageId: widget.messageId,
cipherText: widget.cipherText,
);
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<MessageDecryption?>(
valueListenable: MessageDecryptionCache.instance.listenableFor(
widget.messageId,
),
builder: (context, decryption, _) => widget.builder(decryption),
);
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
class EncryptionLockBadge extends StatelessWidget {
final double size;
final Color? borderColor;
const EncryptionLockBadge({super.key, this.size = 16, this.borderColor});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: cs.primary,
shape: BoxShape.circle,
border: Border.all(color: borderColor ?? cs.surface, width: 1.5),
),
alignment: Alignment.center,
child: Icon(
Symbols.lock,
size: size * 0.62,
weight: 700,
fill: 1,
color: cs.onPrimary,
),
);
}
}
+73 -22
View File
@@ -11,6 +11,8 @@ import '../../backend/modules/messages.dart';
import '../screens/webapp/web_app_screen.dart';
import '../../core/config/app_bubble_behavior.dart';
import '../../core/config/app_bubble_shape.dart';
import '../../core/crypto/message_decryption_cache.dart';
import 'decrypted_text.dart';
import '../../core/utils/bubble_radius.dart';
import '../../core/utils/link_opener.dart';
import '../../core/utils/text_format.dart';
@@ -1237,7 +1239,18 @@ class MessageBubble extends StatelessWidget {
);
}
Widget _buildTextContent(BubbleContext ctx) {
Widget _buildTextContent(BubbleContext ctx) => DecryptedContent(
accountId: message.accountId,
chatId: message.chatId,
messageId: message.id,
cipherText: message.text ?? '',
builder: (decryption) => _buildTextContentBody(ctx, decryption),
);
Widget _buildTextContentBody(
BubbleContext ctx,
MessageDecryption? decryption,
) {
final attachments = message.attachments;
final isForwardedContact =
attachments != null &&
@@ -1267,20 +1280,43 @@ class MessageBubble extends StatelessWidget {
: null,
);
final ranges = message.formatRanges;
final baseTextWidget = isForwarded
? _buildForwardedInlineText(ctx, forwarded)
: (FormattedMessageText.isFormatted(message.text, ranges)
? FormattedMessageText(
text: message.text!,
ranges: ranges,
style: textStyle,
)
: Text(message.text ?? '', style: textStyle));
final decryptedText = decryption?.plaintext;
final Widget baseTextWidget;
if (decryption?.state == MessageDecryptionState.wrongKey) {
baseTextWidget = Text(
'неверный ключ',
style: textStyle.copyWith(
color: ctx.cs.error,
fontStyle: FontStyle.italic,
),
);
} else if (decryptedText != null) {
baseTextWidget = Text(decryptedText, style: textStyle);
} else if (isForwarded) {
baseTextWidget = _buildForwardedInlineText(ctx, forwarded);
} else if (FormattedMessageText.isFormatted(message.text, ranges)) {
baseTextWidget = FormattedMessageText(
text: message.text!,
ranges: ranges,
style: textStyle,
);
} else {
baseTextWidget = Text(message.text ?? '', style: textStyle);
}
final textWidget = _wrapSelectable(baseTextWidget);
final metaWidget = Text(
message.status == 'EDITED' ? '${ctx.clockText} ред.' : ctx.clockText,
style: TextStyle(color: ctx.dim, fontSize: 10),
final metaWidget = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (decryption?.isDecrypted ?? false) ...[
Icon(Symbols.lock, size: 11, weight: 700, fill: 1, color: ctx.dim),
const SizedBox(width: 3),
],
Text(
message.status == 'EDITED' ? '${ctx.clockText} ред.' : ctx.clockText,
style: TextStyle(color: ctx.dim, fontSize: 10),
),
],
);
if (hasReactions) {
@@ -1353,7 +1389,8 @@ class MessageBubble extends StatelessWidget {
final name = reply.senderId == myId
? 'Вы'
: (ContactCache.get(reply.senderId) ?? 'Сообщение');
final preview = reply.previewText();
final rawPreview = reply.previewText();
final quotedId = reply.messageId;
final quote = Container(
padding: const EdgeInsets.fromLTRB(8, 3, 8, 3),
@@ -1376,14 +1413,28 @@ class MessageBubble extends StatelessWidget {
fontWeight: FontWeight.w600,
),
),
if (preview.isNotEmpty)
Text(
preview,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: textColor.withValues(alpha: 0.85),
fontSize: 13,
if (rawPreview.isNotEmpty)
DecryptedContent(
accountId: message.accountId,
chatId: message.chatId,
messageId: quotedId ?? '',
cipherText: quotedId == null ? '' : rawPreview,
builder: (decryption) => Text(
decryption?.state == MessageDecryptionState.wrongKey
? 'неверный ключ'
: (decryption?.plaintext ?? rawPreview),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: decryption?.state == MessageDecryptionState.wrongKey
? cs.error
: textColor.withValues(alpha: 0.85),
fontSize: 13,
fontStyle:
decryption?.state == MessageDecryptionState.wrongKey
? FontStyle.italic
: null,
),
),
),
],
+10 -2
View File
@@ -80,6 +80,10 @@ class PhotoViewerScreen extends StatefulWidget {
final CachedMessage? message;
final PhotoViewerActions? actions;
/// The opened item is a file attachment rendered as an image, so the counter
/// reads "FILE of N" — it has no position within the chat's photo feed.
final bool isFile;
const PhotoViewerScreen({
super.key,
required this.photos,
@@ -87,6 +91,7 @@ class PhotoViewerScreen extends StatefulWidget {
this.chatId,
this.message,
this.actions,
this.isFile = false,
});
PhotoViewerScreen.single(String baseUrl, {super.key})
@@ -94,7 +99,8 @@ class PhotoViewerScreen extends StatefulWidget {
initialIndex = 0,
chatId = null,
message = null,
actions = null;
actions = null,
isFile = false;
@override
State<PhotoViewerScreen> createState() => _PhotoViewerScreenState();
@@ -632,7 +638,9 @@ class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
children: [
if (_feedLoaded)
Text(
l10n.photoViewerCounter(_total - _index, _total),
widget.isFile
? l10n.photoViewerCounterFile(_total)
: l10n.photoViewerCounter(_total - _index, _total),
style: const TextStyle(
color: Colors.white,
fontSize: 16,