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