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
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/core/config/app_frost.dart';
import 'package:komet/frontend/widgets/encryption_lock_badge.dart';
import 'package:komet/frontend/widgets/glossy_pill.dart';
import 'package:komet/frontend/widgets/online_dot.dart';
@@ -18,6 +19,7 @@ class ChatHeaderRow extends StatelessWidget {
final String imageUrl;
final String chatType;
final bool isOfficial;
final bool encrypted;
final int myId;
final ValueListenable<String> headerStatus;
final ValueListenable<int> scheduledCount;
@@ -42,6 +44,7 @@ class ChatHeaderRow extends StatelessWidget {
required this.imageUrl,
required this.chatType,
required this.isOfficial,
this.encrypted = false,
required this.myId,
required this.headerStatus,
required this.scheduledCount,
@@ -373,6 +376,7 @@ class ChatHeaderRow extends StatelessWidget {
final otherId = chatId ^ myId;
final showDot = chatType == 'DIALOG' && myId != 0 && otherId > 0;
return Stack(
clipBehavior: Clip.none,
children: [
avatar,
if (showDot)
@@ -385,6 +389,12 @@ class ChatHeaderRow extends StatelessWidget {
size: dotSize,
),
),
if (encrypted)
Positioned(
left: -2,
bottom: -2,
child: EncryptionLockBadge(size: dotSize + 4),
),
],
);
}
@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/storage/chat_encryption_store.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/primary_loading_button.dart';
import '../../widgets/settings_card.dart';
import '../../widgets/small_spinner.dart';
class ChatEncryptionScreen extends StatefulWidget {
final int accountId;
final int chatId;
const ChatEncryptionScreen({
super.key,
required this.accountId,
required this.chatId,
});
@override
State<ChatEncryptionScreen> createState() => _ChatEncryptionScreenState();
}
class _ChatEncryptionScreenState extends State<ChatEncryptionScreen> {
final _keyController = TextEditingController();
final ValueNotifier<bool> _saving = ValueNotifier(false);
bool _loading = true;
bool _enabled = false;
bool _keyVisible = false;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_keyController.dispose();
_saving.dispose();
super.dispose();
}
Future<void> _load() async {
final store = ChatEncryptionStore.instance;
await store.load();
final key = await store.readKey(widget.accountId, widget.chatId);
if (!mounted) return;
setState(() {
_enabled = store.isEnabled(widget.accountId, widget.chatId);
_keyController.text = key ?? '';
_loading = false;
});
}
Future<void> _save() async {
if (widget.accountId == 0) {
showCustomNotification(context, 'Профиль ещё не загружен');
return;
}
final key = _keyController.text.trim();
if (_enabled && key.isEmpty) {
showCustomNotification(context, 'Введите ключ шифрования');
return;
}
_saving.value = true;
final store = ChatEncryptionStore.instance;
if (key.isEmpty) {
await store.deleteKey(widget.accountId, widget.chatId);
} else {
await store.writeKey(widget.accountId, widget.chatId, key);
}
await store.setEnabled(widget.accountId, widget.chatId, _enabled);
if (!mounted) return;
_saving.value = false;
showCustomNotification(
context,
_enabled ? 'Шифрование включено' : 'Шифрование отключено',
);
Navigator.pop(context, true);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
backgroundColor: cs.surface,
elevation: 0,
leading: IconButton(
icon: Icon(Symbols.arrow_back, color: cs.onSurface, weight: 400),
onPressed: () => Navigator.pop(context),
),
title: Text(
'Шифрование сообщений',
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight: FontWeight.w700,
fontFamily: 'Outfit',
),
),
),
body: _loading
? Center(child: SmallSpinner(size: 36, color: cs.primary))
: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingsCard(
children: [
SettingsToggleTile(
icon: _enabled ? Symbols.lock : Symbols.lock_open,
label: 'Шифровать сообщения',
subtitle:
'Текст сообщений в этом чате будет зашифрован '
'ключом ниже',
value: _enabled,
onChanged: (v) => setState(() => _enabled = v),
),
],
),
const SizedBox(height: 16),
GlossyPill(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
padding: const EdgeInsets.all(20),
depth: 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ключ',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
TextField(
controller: _keyController,
obscureText: !_keyVisible,
enableSuggestions: false,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Введите ключ',
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
suffixIcon: IconButton(
icon: Icon(
_keyVisible
? Symbols.visibility_off
: Symbols.visibility,
color: cs.onSurfaceVariant,
),
onPressed: () =>
setState(() => _keyVisible = !_keyVisible),
),
),
),
const SizedBox(height: 12),
Text(
'Ключ хранится только на этом устройстве. '
'Собеседник должен ввести такой же ключ, иначе он '
'не прочитает сообщения.',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
height: 1.35,
),
),
],
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: PrimaryLoadingButton(
loading: _saving,
onPressed: _save,
child: const Text('Сохранить'),
),
),
],
),
),
);
}
}
@@ -12,6 +12,9 @@ import 'create_channel_flow.dart';
import 'create_group_flow.dart';
import '../contacts/add_contact_sheet.dart';
import '../../widgets/adaptive_shell.dart';
import '../../../core/crypto/message_decryption_cache.dart';
import '../../widgets/decrypted_text.dart';
import '../../widgets/encryption_lock_badge.dart';
import '../../widgets/online_dot.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/glossy_pill.dart';
@@ -52,6 +55,7 @@ import '../../../backend/modules/folders.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/draft_store.dart';
import '../../../core/storage/archived_chats_store.dart';
import '../../../core/storage/chat_encryption_store.dart';
import '../../../core/storage/token_storage.dart';
import '../../../core/storage/chat_activity_store.dart';
import '../../../main.dart'
@@ -579,6 +583,7 @@ class _ChatListScreenState extends State<ChatListScreen>
});
chats.chatsChanged.addListener(_onChatsChanged);
ArchivedChatsStore.instance.revision.addListener(_onArchivedChanged);
ChatEncryptionStore.instance.revision.addListener(_onEncryptionChanged);
DraftStore.instance.revision.addListener(_onDraftsChanged);
AppStories.current.addListener(_onStoriesEnabledChanged);
storiesModule.storiesChanged.addListener(_onStoriesDataChanged);
@@ -624,6 +629,10 @@ class _ChatListScreenState extends State<ChatListScreen>
if (mounted) _requestReload();
}
void _onEncryptionChanged() {
if (mounted) setState(() {});
}
void _onStoriesEnabledChanged() {
if (!mounted) return;
if (!AppStories.current.value) {
@@ -1225,6 +1234,7 @@ class _ChatListScreenState extends State<ChatListScreen>
_settleTimer?.cancel();
chats.chatsChanged.removeListener(_onChatsChanged);
ArchivedChatsStore.instance.revision.removeListener(_onArchivedChanged);
ChatEncryptionStore.instance.revision.removeListener(_onEncryptionChanged);
DraftStore.instance.revision.removeListener(_onDraftsChanged);
AppStories.current.removeListener(_onStoriesEnabledChanged);
storiesModule.storiesChanged.removeListener(_onStoriesDataChanged);
@@ -1645,6 +1655,10 @@ class _ChatListScreenState extends State<ChatListScreen>
messageRanges: isPlaceholder
? const []
: chat.lastMsgFormatRanges,
previewMessageId: isPlaceholder ? null : chat.lastMsgId,
previewCipherText: isPlaceholder
? null
: chat.lastMsgTextOneLine,
),
);
} else {
@@ -1654,6 +1668,7 @@ class _ChatListScreenState extends State<ChatListScreen>
: null;
String fullMsg = "";
String senderPrefix = "";
List<FormatRange> messageRanges = const [];
if (isPlaceholder) {
fullMsg = 'зайдите в чат для подгрузки';
@@ -1661,6 +1676,7 @@ class _ChatListScreenState extends State<ChatListScreen>
var prefixLen = 0;
if (sender?.isNotEmpty == true && chat.id != 0) {
final prefix = "$sender: ";
senderPrefix = prefix;
fullMsg += prefix;
prefixLen = prefix.length;
}
@@ -1702,6 +1718,11 @@ class _ChatListScreenState extends State<ChatListScreen>
ownStatus: _ownStatusFor(chat, isPlaceholder),
ownRead: chat.lastMsgReadByOthers,
messageRanges: messageRanges,
previewMessageId: isPlaceholder ? null : chat.lastMsgId,
previewPrefix: senderPrefix,
previewCipherText: isPlaceholder
? null
: chat.lastMsgText,
),
);
}
@@ -2550,19 +2571,55 @@ class _ChatListScreenState extends State<ChatListScreen>
String? ownStatus,
bool ownRead = false,
List<FormatRange> messageRanges = const [],
int? previewMessageId,
String previewPrefix = '',
String? previewCipherText,
}) {
final cs = Theme.of(context).colorScheme;
final isSelected = _selectedChats.contains(id);
final isEncrypted = ChatEncryptionStore.instance.isEnabled(
_profile?.id ?? 0,
int.tryParse(id) ?? 0,
);
final Widget? statusIcon = (ownStatus != null && draft == null)
? _ownStatusIcon(cs, ownStatus, ownRead)
: null;
final Widget messageLine = _buildPreviewLine(
cs,
message,
messageRanges,
draft,
messageItalic,
);
final canDecryptPreview =
isEncrypted &&
draft == null &&
previewMessageId != null &&
(previewCipherText?.isNotEmpty ?? false);
final Widget messageLine = canDecryptPreview
? DecryptedContent(
accountId: _profile?.id ?? 0,
chatId: int.tryParse(id) ?? 0,
messageId: previewMessageId.toString(),
cipherText: previewCipherText!,
builder: (decryption) => switch (decryption?.state) {
null => _buildPreviewLine(
cs,
message,
messageRanges,
draft,
messageItalic,
),
MessageDecryptionState.wrongKey => _buildPreviewLine(
cs,
'$previewPrefix' 'неверный ключ',
const [],
draft,
true,
),
MessageDecryptionState.decrypted => _buildPreviewLine(
cs,
'$previewPrefix${decryption!.plaintext}',
const [],
draft,
messageItalic,
),
},
)
: _buildPreviewLine(cs, message, messageRanges, draft, messageItalic);
final Widget avatarCircle = CircleAvatar(
radius: 24,
@@ -2645,8 +2702,15 @@ class _ChatListScreenState extends State<ChatListScreen>
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Stack(
clipBehavior: Clip.none,
children: [
avatarCircle,
if (isEncrypted)
const Positioned(
left: -2,
bottom: -2,
child: EncryptionLockBadge(size: 18),
),
if (isSelected)
Positioned(
right: -2,
+213 -21
View File
@@ -40,6 +40,10 @@ import '../../../core/protocol/packet.dart';
import '../../../core/push/push_service.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/chat_activity_store.dart';
import '../../../core/crypto/chat_crypto_service.dart';
import '../../../core/crypto/encrypted_photo.dart';
import '../../../core/crypto/message_decryption_cache.dart';
import '../../../core/storage/chat_encryption_store.dart';
import '../../../core/storage/chat_wallpaper_store.dart';
import '../../../core/storage/draft_store.dart';
import '../../../core/storage/archived_chats_store.dart';
@@ -103,6 +107,7 @@ import '../../widgets/chat_wallpaper_view.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/liquid_glass.dart';
import 'scheduled_messages_screen.dart';
import 'chat_encryption_screen.dart';
import 'chat_wallpaper_preview_screen.dart';
class _DateSeparatorItem {
@@ -263,6 +268,7 @@ class _ChatScreenState extends State<ChatScreen>
final GlobalKey _listKey = GlobalKey();
final ValueNotifier<bool> _hasText = ValueNotifier(false);
bool _isLoading = true;
bool _encryptionEnabled = false;
final ValueNotifier<bool> _showAttachmentPanel = ValueNotifier(false);
late final StickerPanelController _stickers;
final ValueNotifier<UploadStatus> _uploadStatus = ValueNotifier(
@@ -750,6 +756,7 @@ class _ChatScreenState extends State<ChatScreen>
_restoreDraft();
unawaited(_loadPeerKind());
unawaited(_loadWallpaper());
unawaited(_loadEncryption());
unawaited(_refreshBadge());
try {
@@ -1870,6 +1877,9 @@ class _ChatScreenState extends State<ChatScreen>
_applyEffectiveWallpaper,
);
}
if (_encryptionListening) {
ChatEncryptionStore.instance.revision.removeListener(_applyEncryption);
}
_headerStatusNotifier.dispose();
_otherReadTime.dispose();
_chatController.dispose();
@@ -2960,6 +2970,7 @@ class _ChatScreenState extends State<ChatScreen>
imageUrl: widget.imageUrl,
chatType: widget.chatType,
isOfficial: chat?.isOfficial ?? false,
encrypted: _encryptionEnabled,
myId: _myId,
headerStatus: _headerStatusNotifier,
scheduledCount: _scheduledCount,
@@ -3049,6 +3060,11 @@ class _ChatScreenState extends State<ChatScreen>
label: 'Очистить историю',
onTap: _clearHistory,
),
ChatMenuItem(
icon: _encryptionEnabled ? Symbols.lock : Symbols.lock_open,
label: 'Шифрование сообщений',
onTap: _openEncryptionSettings,
),
ChatMenuItem(
icon: Symbols.delete,
label: 'Удалить чат',
@@ -3113,6 +3129,43 @@ class _ChatScreenState extends State<ChatScreen>
);
}
bool _encryptionListening = false;
Future<void> _loadEncryption() async {
await ChatEncryptionStore.instance.load();
if (!mounted) return;
if (!_encryptionListening) {
_encryptionListening = true;
ChatEncryptionStore.instance.revision.addListener(_applyEncryption);
}
_applyEncryption();
}
void _applyEncryption() {
if (!mounted) return;
final enabled = ChatEncryptionStore.instance.isEnabled(
_myId,
widget.chatId,
);
if (enabled != _encryptionEnabled) {
setState(() => _encryptionEnabled = enabled);
}
if (enabled && _myId != 0) {
unawaited(ChatCryptoService.instance.warmKey(_myId, widget.chatId));
}
}
Future<void> _openEncryptionSettings() async {
if (_myId == 0) return;
await pushSwipeable(
context,
(context) =>
ChatEncryptionScreen(accountId: _myId, chatId: widget.chatId),
);
if (!mounted) return;
_applyEncryption();
}
bool _wallpaperListening = false;
Future<void> _loadWallpaper() async {
@@ -3482,6 +3535,36 @@ class _ChatScreenState extends State<ChatScreen>
return result;
}
Future<String?> _encryptOutgoing(String text) async {
if (!_encryptionEnabled || _myId == 0) return text;
final result = await ChatCryptoService.instance.encrypt(
_myId,
widget.chatId,
text,
);
if (result.isOk) {
if (result.text!.length > kMaxEncryptedMessageLength) {
if (mounted) {
showCustomNotification(
context,
'Слишком длинное сообщение. Разделите на несколько',
);
}
return null;
}
return result.text;
}
if (mounted) {
showCustomNotification(
context,
result.failure == CryptoFailure.noKey
? 'Не задан ключ шифрования'
: 'Не удалось зашифровать сообщение',
);
}
return null;
}
Future<void> _sendMessage() async {
final content = _messageController.buildContent();
final rawText = content.text;
@@ -3505,6 +3588,10 @@ class _ChatScreenState extends State<ChatScreen>
}
}
final wireText = await _encryptOutgoing(text);
if (wireText == null || !mounted) return;
final encrypted = wireText != text;
final tempId = _nextTempId();
final now = DateTime.now().millisecondsSinceEpoch;
final online = api.state == SessionState.online;
@@ -3531,7 +3618,9 @@ class _ChatScreenState extends State<ChatScreen>
_replyTo.value = null;
_replySourceChatId = null;
final elements = _trimmedElements(content.elements, rawText, text);
final elements = encrypted
? const <Map<String, dynamic>>[]
: _trimmedElements(content.elements, rawText, text);
final Map<String, dynamic>? composedPayload =
(replyPayload == null && elements.isEmpty)
? null
@@ -3542,11 +3631,12 @@ class _ChatScreenState extends State<ChatScreen>
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
text: text,
text: wireText,
time: now,
status: online ? 'sending' : 'pending',
payload: composedPayload,
);
if (encrypted) MessageDecryptionCache.instance.seed(tempId, text);
_hasText.value = false;
_lastSentId = tempId;
@@ -3565,7 +3655,7 @@ class _ChatScreenState extends State<ChatScreen>
widget.chatId,
messageId: tempId,
time: now,
text: text,
text: wireText,
status: composed.status ?? 'sending',
elements: elements,
),
@@ -3587,14 +3677,14 @@ class _ChatScreenState extends State<ChatScreen>
_myId,
widget.chatId,
widget.commentPostId!,
text,
wireText,
replyToMessageId: replyId,
elements: elements,
)
: await messagesModule.sendMessage(
_myId,
widget.chatId,
text,
wireText,
replyToMessageId: replyId,
replySourceChatId: replySourceChatId,
elements: elements,
@@ -3607,11 +3697,14 @@ class _ChatScreenState extends State<ChatScreen>
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
text: text,
text: wireText,
time: now,
status: 'sent',
payload: composedPayload,
);
if (encrypted) {
MessageDecryptionCache.instance.adopt(tempId, sent.id);
}
_messages[index] = sent;
_bumpMessages();
if (!_commentsMode) {
@@ -3622,7 +3715,7 @@ class _ChatScreenState extends State<ChatScreen>
widget.chatId,
messageId: sent.id,
time: now,
text: text,
text: wireText,
status: 'sent',
elements: elements,
),
@@ -5591,13 +5684,14 @@ class _ChatScreenState extends State<ChatScreen>
String tempId,
String status, {
FileAttachment? attachment,
String? realId,
}) {
if (!mounted) return;
final idx = _messages.indexWhere((m) => m.id == tempId);
if (idx == -1) return;
final old = _messages[idx];
_messages[idx] = CachedMessage(
id: tempId,
id: realId != null && realId.isNotEmpty ? realId : tempId,
accountId: old.accountId,
chatId: old.chatId,
senderId: old.senderId,
@@ -5621,12 +5715,16 @@ class _ChatScreenState extends State<ChatScreen>
);
_showAttachmentPanel.value = false;
try {
final ok = await messagesModule.sendFileMessage(
final realId = await messagesModule.sendFileMessage(
widget.chatId,
entry.fileId,
token: entry.token,
);
_updateFileMessageStatus(tempId, ok ? 'sent' : 'error');
_updateFileMessageStatus(
tempId,
realId != null ? 'sent' : 'error',
realId: realId,
);
} catch (_) {
_updateFileMessageStatus(tempId, 'error');
}
@@ -5635,13 +5733,14 @@ class _ChatScreenState extends State<ChatScreen>
Future<bool> _sendFileById(int fileId) async {
final tempId = _addOptimisticFileMessage(FileAttachment(fileId: fileId));
try {
final ok = await messagesModule.sendFileMessage(widget.chatId, fileId);
final realId = await messagesModule.sendFileMessage(widget.chatId, fileId);
final ok = realId != null;
if (!mounted) return ok;
if (ok) {
FileHistoryCache.add(
FileHistoryEntry(fileId: fileId, sentAt: DateTime.now()),
);
_updateFileMessageStatus(tempId, 'sent');
_updateFileMessageStatus(tempId, 'sent', realId: realId);
_showAttachmentPanel.value = false;
} else {
_updateFileMessageStatus(tempId, 'error');
@@ -5675,11 +5774,17 @@ class _ChatScreenState extends State<ChatScreen>
? _sendPhotos
: (picked, caption) =>
_sendScheduledPhotos(picked, caption, scheduledTime),
onPickFile: scheduledTime == null
? _pickAndUploadFile
: () => _pickAndUploadFile(scheduledTime: scheduledTime),
onShareLocation: _shareLocation,
onCreatePoll: _createPoll,
onPickFile: _encryptionEnabled
? () => _refuseUnencrypted('Файлы')
: (scheduledTime == null
? _pickAndUploadFile
: () => _pickAndUploadFile(scheduledTime: scheduledTime)),
onShareLocation: _encryptionEnabled
? () => _refuseUnencrypted('Геолокацию')
: _shareLocation,
onCreatePoll: _encryptionEnabled
? () => _refuseUnencrypted('Опросы')
: _createPoll,
);
if (!mounted || !hadKeyboard) return;
_messageFocusNode.requestFocus();
@@ -5689,6 +5794,7 @@ class _ChatScreenState extends State<ChatScreen>
Future<void> _sendPhotos(List<PickedPhoto> picked, String caption) async {
if (_myId == 0) return;
if (_encryptionEnabled) return _sendEncryptedPhotos(picked, caption);
final videos = picked.where((ph) => ph.item.isVideo).toList();
final photos = picked.where((ph) => !ph.item.isVideo).toList();
if (photos.isEmpty && videos.isEmpty) return;
@@ -6208,11 +6314,88 @@ class _ChatScreenState extends State<ChatScreen>
_photoUploadProgress.remove(tempId)?.dispose();
}
void _refuseUnencrypted(String what) {
if (!mounted) return;
_showAttachmentPanel.value = false;
showCustomNotification(context, '$what пока нельзя зашифровать');
}
Future<void> _sendEncryptedPhotos(
List<PickedPhoto> picked,
String caption,
) async {
final photos = picked.where((ph) => !ph.item.isVideo).toList();
if (photos.length != picked.length && mounted) {
showCustomNotification(context, 'Видео пока нельзя зашифровать');
}
if (photos.isEmpty) return;
for (final photo in photos) {
final source =
photo.editedFile ??
photo.item.localFile ??
await photo.item.originFile();
if (source == null || !mounted) continue;
_showAttachmentPanel.value = false;
_uploadStatus.value = const UploadStatus(active: true);
final stamp = DateTime.now().microsecondsSinceEpoch.toString();
final prepared = await prepareEncryptedPhoto(
accountId: _myId,
chatId: widget.chatId,
source: source,
stamp: stamp,
);
if (!mounted) return;
if (!prepared.isOk) {
_uploadStatus.value = const UploadStatus();
showCustomNotification(
context,
prepared.failure == CryptoFailure.noKey
? 'Не задан ключ шифрования'
: 'Не удалось зашифровать фото',
);
return;
}
final encrypted = prepared.file!;
await _uploadAsFile(
source: encrypted,
filename: 'photo_$stamp$kEncryptedPhotoExtension',
size: await encrypted.length(),
);
if (!mounted) return;
}
if (caption.isNotEmpty) {
final wire = await _encryptOutgoing(caption);
if (wire != null && mounted) {
await messagesModule.sendMessage(_myId, widget.chatId, wire);
}
}
}
Future<void> _pickAndUploadFile({int? scheduledTime}) async {
final result = await FilePicker.platform.pickFiles();
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
if (file.path == null) return;
final picked = result.files.first;
if (picked.path == null) return;
await _uploadAsFile(
source: File(picked.path!),
filename: picked.name,
size: picked.size,
scheduledTime: scheduledTime,
);
}
Future<void> _uploadAsFile({
required File source,
required String filename,
required int size,
int? scheduledTime,
}) async {
final file = (name: filename, size: size);
final done = Completer<void>();
_showAttachmentPanel.value = false;
_uploadStatus.value = UploadStatus(active: true, total: file.size);
@@ -6237,7 +6420,7 @@ class _ChatScreenState extends State<ChatScreen>
_uploadSub = fileUploader
.upload(
chatId: widget.chatId,
file: File(file.path!),
file: source,
filename: file.name,
totalSize: file.size,
scheduledTime: scheduledTime,
@@ -6269,7 +6452,12 @@ class _ChatScreenState extends State<ChatScreen>
speedBps: notifSpeedBps,
);
}
case UploadDone(:final fileId, :final token, :final url):
case UploadDone(
:final fileId,
:final token,
:final url,
:final messageId,
):
stopNotif();
FileHistoryCache.add(
FileHistoryEntry(
@@ -6292,6 +6480,7 @@ class _ChatScreenState extends State<ChatScreen>
_updateFileMessageStatus(
tempId!,
'sent',
realId: messageId,
attachment: FileAttachment(
fileId: fileId,
fileToken: token,
@@ -6326,6 +6515,7 @@ class _ChatScreenState extends State<ChatScreen>
}
_uploadStatus.value = const UploadStatus();
_uploadSub = null;
if (!done.isCompleted) done.complete();
},
onError: (Object e) {
if (!mounted) return;
@@ -6334,8 +6524,10 @@ class _ChatScreenState extends State<ChatScreen>
if (tempId != null) _updateFileMessageStatus(tempId, 'error');
_uploadStatus.value = const UploadStatus();
_uploadSub = null;
if (!done.isCompleted) done.complete();
},
);
return done.future;
}
}
@@ -288,8 +288,8 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
backgroundColor: Colors.transparent,
builder: (_) => _SendByIdSheet(
onSend: (id) async {
final ok = await messagesModule.sendFileMessage(chatId, id);
if (!ok) return false;
final sentId = await messagesModule.sendFileMessage(chatId, id);
if (sentId == null) return false;
final newest = await CloudStorageModule.fetchLatestFile(
messagesModule,
accountId,
@@ -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,