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
+9 -2
View File
@@ -26,12 +26,18 @@ class UploadDone extends UploadEvent {
final String? url;
final String filename;
final int size;
/// Server-assigned message id. Without it the optimistic message keeps its
/// local temp id and download URLs cannot be resolved until a restart.
final String? messageId;
const UploadDone({
required this.fileId,
required this.filename,
required this.size,
this.token,
this.url,
this.messageId,
});
}
@@ -139,14 +145,14 @@ class FileUploader {
return;
}
final ok = await messages.sendFileMessage(
final messageId = await messages.sendFileMessage(
chatId,
info.fileId,
token: info.token,
scheduledTime: scheduledTime,
);
if (cancelled) return;
if (!ok) {
if (messageId == null) {
ctrl.add(const UploadError('send_failed'));
return;
}
@@ -158,6 +164,7 @@ class FileUploader {
url: info.url,
filename: filename,
size: totalSize,
messageId: messageId,
),
);
} catch (e) {
+7 -4
View File
@@ -1314,7 +1314,10 @@ class MessagesModule {
);
}
Future<bool> sendFileMessage(
/// Returns the server-assigned message id, or null on failure. The id is
/// required to later resolve a download URL — a message still carrying its
/// local temp id resolves to messageId 0 and the server rejects it.
Future<String?> sendFileMessage(
int chatId,
int fileId, {
String? token,
@@ -1343,12 +1346,12 @@ class MessagesModule {
}
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
return _sendWithNotReadyRetry<bool>(
return _sendWithNotReadyRetry<String?>(
payload: payload,
maxAttempts: maxAttempts,
retryDelay: retryDelay,
onResult: (response) => response.isOk,
onExhausted: false,
onResult: (response) => _sentMessageMap(response)?['id']?.toString(),
onExhausted: null,
);
}
+197
View File
@@ -0,0 +1,197 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:komet_crypto/komet_crypto.dart' as kc;
import '../storage/chat_encryption_store.dart';
import '../utils/logger.dart';
const int kMaxEncryptedMessageLength = 1000;
enum CryptoFailure { noKey, wrongKey, notEncrypted, malformed, unavailable }
class CryptoResult {
final String? text;
final CryptoFailure? failure;
const CryptoResult.ok(String this.text) : failure = null;
const CryptoResult.failed(CryptoFailure this.failure) : text = null;
bool get isOk => text != null;
}
class ChatCryptoService {
ChatCryptoService._() {
ChatEncryptionStore.instance.revision.addListener(clearKeys);
}
static final ChatCryptoService instance = ChatCryptoService._();
final Map<String, Uint8List> _keys = {};
final Map<String, Future<Uint8List?>> _pending = {};
Future<void>? _init;
bool _unavailable = false;
String _cacheKey(int accountId, int chatId) => '$accountId/$chatId';
void clearKeys() {
_keys.clear();
_pending.clear();
}
Future<bool> _ensureInitialized() async {
if (_unavailable) return false;
try {
await (_init ??= kc.RustLib.init());
return true;
} catch (e) {
_init = null;
_unavailable = true;
logger.w('komet_crypto init failed: $e');
return false;
}
}
Future<Uint8List?> _keyFor(int accountId, int chatId) {
final cacheKey = _cacheKey(accountId, chatId);
final cached = _keys[cacheKey];
if (cached != null) return Future.value(cached);
return _pending[cacheKey] ??= _deriveKey(accountId, chatId, cacheKey);
}
Future<Uint8List?> _deriveKey(
int accountId,
int chatId,
String cacheKey,
) async {
try {
if (!await _ensureInitialized()) return null;
final password = await ChatEncryptionStore.instance.readKey(
accountId,
chatId,
);
if (password == null || password.isEmpty) return null;
final key = await kc.deriveKey(password: password);
_keys[cacheKey] = key;
return key;
} catch (e) {
logger.w('derive key for chat $chatId: $e');
return null;
} finally {
_pending.remove(cacheKey);
}
}
bool isEnabled(int accountId, int chatId) =>
ChatEncryptionStore.instance.isEnabled(accountId, chatId);
Future<void> warmKey(int accountId, int chatId) => _keyFor(accountId, chatId);
Future<CryptoResult> encrypt(
int accountId,
int chatId,
String plaintext,
) async {
final key = await _keyFor(accountId, chatId);
if (key == null) {
return CryptoResult.failed(
_unavailable ? CryptoFailure.unavailable : CryptoFailure.noKey,
);
}
try {
return CryptoResult.ok(
await kc.encryptMessage(plaintext: plaintext, key: key),
);
} catch (e) {
logger.w('encrypt for chat $chatId: $e');
return const CryptoResult.failed(CryptoFailure.unavailable);
}
}
Future<CryptoResult> decrypt(int accountId, int chatId, String text) async {
final key = await _keyFor(accountId, chatId);
if (key == null) {
return CryptoResult.failed(
_unavailable ? CryptoFailure.unavailable : CryptoFailure.noKey,
);
}
try {
return CryptoResult.ok(await kc.decryptMessage(text: text, key: key));
} catch (e) {
return CryptoResult.failed(_failureFromCode(e.toString()));
}
}
Future<CryptoFailure?> encryptImageFile(
int accountId,
int chatId,
String sourcePath,
String destPath,
) => _imageOp(
accountId,
chatId,
() => kc.encryptImageFile(
sourcePath: sourcePath,
destPath: destPath,
key: _keys[_cacheKey(accountId, chatId)]!,
),
);
Future<CryptoFailure?> decryptImageFile(
int accountId,
int chatId,
String sourcePath,
String destPath,
) => _imageOp(
accountId,
chatId,
() => kc.decryptImageFile(
sourcePath: sourcePath,
destPath: destPath,
key: _keys[_cacheKey(accountId, chatId)]!,
),
);
Future<CryptoFailure?> _imageOp(
int accountId,
int chatId,
Future<void> Function() run,
) async {
final key = await _keyFor(accountId, chatId);
if (key == null) {
return _unavailable ? CryptoFailure.unavailable : CryptoFailure.noKey;
}
try {
await run();
return null;
} catch (e) {
logger.w('image crypto for chat $chatId: $e');
return _failureFromCode(e.toString());
}
}
Future<bool> looksEncryptedImage(String path) async {
if (!await _ensureInitialized()) return false;
try {
return await kc.looksEncryptedImageFile(path: path);
} catch (_) {
return false;
}
}
Future<bool> looksEncrypted(String text) async {
if (!await _ensureInitialized()) return false;
try {
return await kc.looksEncrypted(text: text);
} catch (_) {
return false;
}
}
CryptoFailure _failureFromCode(String message) {
if (message.contains('wrong_key')) return CryptoFailure.wrongKey;
if (message.contains('not_encrypted')) return CryptoFailure.notEncrypted;
if (message.contains('malformed')) return CryptoFailure.malformed;
return CryptoFailure.unavailable;
}
}
+103
View File
@@ -0,0 +1,103 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:path_provider/path_provider.dart';
import '../utils/logger.dart';
import '../utils/media_cache.dart';
import 'chat_crypto_service.dart';
const String kEncryptedPhotoExtension = '.png';
class EncryptedPhotoResult {
final File? file;
final CryptoFailure? failure;
const EncryptedPhotoResult.ok(File this.file) : failure = null;
const EncryptedPhotoResult.failed(CryptoFailure this.failure) : file = null;
bool get isOk => file != null;
}
/// Re-encodes an arbitrary image into lossless PNG. Encryption needs a format
/// that survives byte-for-byte; a re-encoded JPEG would not.
Future<File?> reencodeAsPng(File source, String destPath) async {
try {
final bytes = await source.readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
final data = await frame.image.toByteData(format: ui.ImageByteFormat.png);
frame.image.dispose();
codec.dispose();
if (data == null) return null;
final dest = File(destPath);
await dest.writeAsBytes(data.buffer.asUint8List(), flush: true);
return dest;
} catch (e) {
logger.w('png re-encode failed: $e');
return null;
}
}
Future<Directory> _scratchDir() async {
final dir = Directory('${(await getTemporaryDirectory()).path}/komet_enc');
if (!await dir.exists()) await dir.create(recursive: true);
return dir;
}
/// Picked image → PNG → encrypted noise PNG, ready to upload as a file.
Future<EncryptedPhotoResult> prepareEncryptedPhoto({
required int accountId,
required int chatId,
required File source,
required String stamp,
}) async {
final dir = await _scratchDir();
final pngPath = '${dir.path}/plain_$stamp.png';
final encPath = '${dir.path}/enc_$stamp.png';
final png = await reencodeAsPng(source, pngPath);
if (png == null) {
return const EncryptedPhotoResult.failed(CryptoFailure.malformed);
}
final failure = await ChatCryptoService.instance.encryptImageFile(
accountId,
chatId,
png.path,
encPath,
);
await _quietDelete(png);
if (failure != null) return EncryptedPhotoResult.failed(failure);
return EncryptedPhotoResult.ok(File(encPath));
}
/// Downloaded noise PNG → original photo, cached for the viewer.
Future<EncryptedPhotoResult> openEncryptedPhoto({
required int accountId,
required int chatId,
required File encrypted,
required String cacheName,
}) async {
final target = await MediaCache.fileFor('decrypted_$cacheName');
if (await target.exists() && await target.length() > 0) {
return EncryptedPhotoResult.ok(target);
}
final failure = await ChatCryptoService.instance.decryptImageFile(
accountId,
chatId,
encrypted.path,
target.path,
);
if (failure != null) {
await _quietDelete(target);
return EncryptedPhotoResult.failed(failure);
}
return EncryptedPhotoResult.ok(target);
}
Future<void> _quietDelete(File file) async {
try {
if (await file.exists()) await file.delete();
} catch (_) {}
}
@@ -0,0 +1,111 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/foundation.dart';
import '../storage/chat_encryption_store.dart';
import 'chat_crypto_service.dart';
enum MessageDecryptionState { decrypted, wrongKey }
@immutable
class MessageDecryption {
final String? plaintext;
final MessageDecryptionState state;
const MessageDecryption.decrypted(String this.plaintext)
: state = MessageDecryptionState.decrypted;
const MessageDecryption.wrongKey()
: plaintext = null,
state = MessageDecryptionState.wrongKey;
bool get isDecrypted => state == MessageDecryptionState.decrypted;
}
class MessageDecryptionCache {
MessageDecryptionCache._() {
ChatEncryptionStore.instance.revision.addListener(clear);
}
static final MessageDecryptionCache instance = MessageDecryptionCache._();
static const int _maxEntries = 1000;
final LinkedHashMap<String, ValueNotifier<MessageDecryption?>> _entries =
LinkedHashMap();
final Set<String> _inFlight = {};
ValueListenable<MessageDecryption?> listenableFor(String messageId) =>
_entryFor(messageId);
ValueNotifier<MessageDecryption?> _entryFor(String messageId) =>
_entries[messageId] ??= ValueNotifier<MessageDecryption?>(null);
void _evictStale(String keep) {
while (_entries.length > _maxEntries) {
final oldest = _entries.keys.first;
if (oldest == keep) break;
_entries.remove(oldest);
}
}
void seed(String messageId, String plaintext) {
_entryFor(messageId).value = MessageDecryption.decrypted(plaintext);
}
void adopt(String fromMessageId, String toMessageId) {
final value = _entries[fromMessageId]?.value;
if (value != null) _entryFor(toMessageId).value = value;
}
void request({
required int accountId,
required int chatId,
required String messageId,
required String cipherText,
}) {
if (cipherText.isEmpty) return;
if (!ChatCryptoService.instance.isEnabled(accountId, chatId)) return;
if (_entryFor(messageId).value != null) return;
if (!_inFlight.add(messageId)) return;
_evictStale(messageId);
unawaited(_resolve(accountId, chatId, messageId, cipherText));
}
Future<void> _resolve(
int accountId,
int chatId,
String messageId,
String cipherText,
) async {
try {
final crypto = ChatCryptoService.instance;
final result = await crypto.decrypt(accountId, chatId, cipherText);
if (result.isOk) {
_entryFor(messageId).value = MessageDecryption.decrypted(result.text!);
return;
}
switch (result.failure) {
case CryptoFailure.wrongKey:
_entryFor(messageId).value = const MessageDecryption.wrongKey();
case CryptoFailure.noKey:
if (await crypto.looksEncrypted(cipherText)) {
_entryFor(messageId).value = const MessageDecryption.wrongKey();
}
case CryptoFailure.notEncrypted:
case CryptoFailure.malformed:
case CryptoFailure.unavailable:
case null:
break;
}
} finally {
_inFlight.remove(messageId);
}
}
void clear() {
_entries.clear();
_inFlight.clear();
}
}
@@ -0,0 +1,38 @@
import 'per_chat_json_store.dart';
import 'token_storage.dart';
class ChatEncryptionStore extends PerChatJsonStore<bool> {
ChatEncryptionStore._()
: super(
prefsKey: 'chat_encryption',
fromJson: (raw) => raw == true ? true : null,
toJson: (value) => value,
);
static final ChatEncryptionStore instance = ChatEncryptionStore._();
static const String _keyPrefix = 'chat_encryption_key';
bool isEnabled(int accountId, int chatId) => read(accountId, chatId) == true;
Future<void> setEnabled(int accountId, int chatId, bool enabled) =>
write(accountId, chatId, enabled ? true : null);
Future<String?> readKey(int accountId, int chatId) async {
if (accountId == 0) return null;
return TokenStorage.readSecure(_secureKey(accountId, chatId));
}
Future<void> writeKey(int accountId, int chatId, String key) async {
if (accountId == 0) return;
await TokenStorage.writeSecure(_secureKey(accountId, chatId), key);
}
Future<void> deleteKey(int accountId, int chatId) async {
if (accountId == 0) return;
await TokenStorage.deleteSecure(_secureKey(accountId, chatId));
}
String _secureKey(int accountId, int chatId) =>
'${_keyPrefix}_${accountId}_$chatId';
}
+15
View File
@@ -15,17 +15,29 @@ class FileDownloadResult {
/// [cacheName] — стабильное имя в кэше (например, `<fileId>_имя.ext`).
/// [resolveUrl] вызывается лениво — только если файла ещё нет в кэше,
/// чтобы не дёргать сервер за временной ссылкой повторно.
/// [onReady] вызывается как только файл лежит на диске — до открытия во
/// внешнем приложении, которое может не возвращать управление, пока его не
/// закроют. Без этого индикатор загрузки висел бы всё это время.
Future<FileDownloadResult> openCachedFile(
String cacheName,
Future<String?> Function() resolveUrl, {
void Function(double progress)? onProgress,
void Function()? onReady,
}) async {
var readyFired = false;
void ready() {
if (readyFired) return;
readyFired = true;
onReady?.call();
}
try {
var file = await MediaCache.existing(cacheName);
if (file == null) {
final url = await resolveUrl();
if (url == null || url.isEmpty) {
ready();
return const FileDownloadResult(ok: false, error: 'нет ссылки');
}
file = await MediaCache.getOrDownload(
@@ -34,10 +46,12 @@ Future<FileDownloadResult> openCachedFile(
onProgress: onProgress,
);
if (file == null) {
ready();
return const FileDownloadResult(ok: false, error: 'ошибка загрузки');
}
}
ready();
final opened = await OpenFilex.open(file.path);
return FileDownloadResult(
ok: opened.type == ResultType.done,
@@ -45,6 +59,7 @@ Future<FileDownloadResult> openCachedFile(
error: opened.type == ResultType.done ? null : opened.message,
);
} catch (e) {
ready();
return FileDownloadResult(ok: false, error: e.toString());
}
}
@@ -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,
+8
View File
@@ -545,6 +545,14 @@
}
}
},
"photoViewerCounterFile": "FILE of {total}",
"@photoViewerCounterFile": {
"placeholders": {
"total": {
"type": "int"
}
}
},
"photoViewerSentToday": "{sender} • today at {time}",
"@photoViewerSentToday": {
"placeholders": {
+6
View File
@@ -2606,6 +2606,12 @@ abstract class AppLocalizations {
/// **'Photo {index} of {total}'**
String photoViewerCounter(int index, int total);
/// No description provided for @photoViewerCounterFile.
///
/// In en, this message translates to:
/// **'FILE of {total}'**
String photoViewerCounterFile(int total);
/// No description provided for @photoViewerSentToday.
///
/// In en, this message translates to:
+5
View File
@@ -1336,6 +1336,11 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Photo $index of $total';
}
@override
String photoViewerCounterFile(int total) {
return 'FILE of $total';
}
@override
String photoViewerSentToday(String sender, String time) {
return '$sender • today at $time';
+5
View File
@@ -1344,6 +1344,11 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Фото $index из $total';
}
@override
String photoViewerCounterFile(int total) {
return 'ФАЙЛ из $total';
}
@override
String photoViewerSentToday(String sender, String time) {
return '$sender • сегодня в $time';
+1
View File
@@ -441,6 +441,7 @@
"sharedGoToMessage": "Перейти к сообщению",
"sharedDownload": "Скачать",
"photoViewerCounter": "Фото {index} из {total}",
"photoViewerCounterFile": "ФАЙЛ из {total}",
"photoViewerSentToday": "{sender} • сегодня в {time}",
"photoViewerSentOn": "{sender} • {date} в {time}",
"photoViewerSaveAs": "Сохранить как…",
+2
View File
@@ -19,6 +19,7 @@ import 'core/cache/self_presence.dart';
import 'core/storage/app_instance.dart';
import 'core/storage/draft_store.dart';
import 'core/storage/archived_chats_store.dart';
import 'core/storage/chat_encryption_store.dart';
import 'core/config/app_accent.dart';
import 'core/config/app_amoled.dart';
import 'core/config/app_show_extra_info.dart';
@@ -239,6 +240,7 @@ void main(List<String> args) async {
await FileHistoryCache.load(prefs);
await DraftStore.instance.load();
await ArchivedChatsStore.instance.load();
await ChatEncryptionStore.instance.load();
await KometSettings.load();
if (KometSettings.ghostMode.value) SelfPresence.markOffline();
await ContactCache.load();