feat: работа с шифрованным текстом/фото
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -545,6 +545,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"photoViewerCounterFile": "FILE of {total}",
|
||||
"@photoViewerCounterFile": {
|
||||
"placeholders": {
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"photoViewerSentToday": "{sender} • today at {time}",
|
||||
"@photoViewerSentToday": {
|
||||
"placeholders": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -441,6 +441,7 @@
|
||||
"sharedGoToMessage": "Перейти к сообщению",
|
||||
"sharedDownload": "Скачать",
|
||||
"photoViewerCounter": "Фото {index} из {total}",
|
||||
"photoViewerCounterFile": "ФАЙЛ из {total}",
|
||||
"photoViewerSentToday": "{sender} • сегодня в {time}",
|
||||
"photoViewerSentOn": "{sender} • {date} в {time}",
|
||||
"photoViewerSaveAs": "Сохранить как…",
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user