diff --git a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt index 16a5f38..0b8b283 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -40,6 +40,14 @@ object MessageCrypto { val nonce: String // Hex-encoded 24-byte nonce ) + /** + * Результат расшифровки входящего сообщения + */ + data class DecryptedIncoming( + val plaintext: String, // Расшифрованный текст + val plainKeyAndNonce: ByteArray // Raw key+nonce для расшифровки attachments + ) + /** * XChaCha20-Poly1305 шифрование (совместимо с @noble/ciphers в RN) * @@ -531,10 +539,19 @@ object MessageCrypto { return originalBytes } + /** + * Результат шифрования для отправки + */ + data class EncryptedForSending( + val ciphertext: String, // Hex-encoded encrypted message + val encryptedKey: String, // ECDH+AES encrypted key (for recipient) + val plainKeyAndNonce: ByteArray // Raw key+nonce for encrypting attachments + ) + /** * Полное шифрование сообщения для отправки */ - fun encryptForSending(plaintext: String, recipientPublicKey: String): Pair { + fun encryptForSending(plaintext: String, recipientPublicKey: String): EncryptedForSending { android.util.Log.d("MessageCrypto", "=".repeat(100)) android.util.Log.d("MessageCrypto", "🚀🚀🚀 START ENCRYPTION FOR SENDING 🚀🚀🚀") android.util.Log.d("MessageCrypto", "=".repeat(100)) @@ -581,17 +598,18 @@ object MessageCrypto { android.util.Log.d("MessageCrypto", " • Encrypted key: ${encryptedKey.take(60)}... (${encryptedKey.length} chars)") android.util.Log.d("MessageCrypto", "=".repeat(100) + "\n") - return kotlin.Pair(encrypted.ciphertext, encryptedKey) + return EncryptedForSending(encrypted.ciphertext, encryptedKey, keyAndNonce) } /** * Полная расшифровка входящего сообщения + * Возвращает текст и plainKeyAndNonce для расшифровки attachments */ - fun decryptIncoming( + fun decryptIncomingFull( ciphertext: String, encryptedKey: String, myPrivateKey: String - ): String { + ): DecryptedIncoming { android.util.Log.d("MessageCrypto", "=".repeat(100)) android.util.Log.d("MessageCrypto", "🔓🔓🔓 START DECRYPTION OF INCOMING MESSAGE 🔓🔓🔓") android.util.Log.d("MessageCrypto", "=".repeat(100)) @@ -635,9 +653,18 @@ object MessageCrypto { android.util.Log.d("MessageCrypto", "FINAL OUTPUT: '$plaintext'") android.util.Log.d("MessageCrypto", "=".repeat(100) + "\n") - return plaintext + return DecryptedIncoming(plaintext, keyAndNonce) } + /** + * Совместимая версия decryptIncoming (возвращает только текст) + */ + fun decryptIncoming( + ciphertext: String, + encryptedKey: String, + myPrivateKey: String + ): String = decryptIncomingFull(ciphertext, encryptedKey, myPrivateKey).plaintext + /** * Расшифровка MESSAGES attachment blob * Формат: ivBase64:ciphertextBase64 @@ -719,6 +746,139 @@ object MessageCrypto { null } } + + /** + * 🔥 Шифрует reply blob для attachments (как в React Native) + * Использует PBKDF2 + AES-CBC с тем же ключом что и основное сообщение + * + * В RN: encodeWithPassword(key.toString('utf-8'), JSON.stringify(reply)) + * где key = Buffer.concat([chacha_key, nonce]) - 56 bytes as UTF-8 string + * + * Формат выхода: "ivBase64:ciphertextBase64" (совместим с desktop) + */ + fun encryptReplyBlob(replyJson: String, plainKeyAndNonce: ByteArray): String { + return try { + android.util.Log.d("MessageCrypto", "🔐 Encrypting reply blob...") + android.util.Log.d("MessageCrypto", " - ReplyJson length: ${replyJson.length}") + android.util.Log.d("MessageCrypto", " - PlainKeyAndNonce length: ${plainKeyAndNonce.size}") + + // Convert keyAndNonce to UTF-8 string (as password) - same as RN: key.toString('utf-8') + val password = String(plainKeyAndNonce, Charsets.UTF_8) + android.util.Log.d("MessageCrypto", " - Password length: ${password.length}") + + // Compress with pako (deflate) + val deflater = java.util.zip.Deflater() + deflater.setInput(replyJson.toByteArray(Charsets.UTF_8)) + deflater.finish() + val compressedBuffer = ByteArray(replyJson.length * 2 + 100) + val compressedSize = deflater.deflate(compressedBuffer) + deflater.end() + val compressed = compressedBuffer.copyOf(compressedSize) + android.util.Log.d("MessageCrypto", " - Compressed size: ${compressed.size}") + + // PBKDF2 key derivation (matching RN: crypto.PBKDF2(password, 'rosetta', {keySize: 256/32, iterations: 1000})) + val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") + val spec = javax.crypto.spec.PBEKeySpec( + password.toCharArray(), + "rosetta".toByteArray(Charsets.UTF_8), + 1000, + 256 + ) + val secretKey = factory.generateSecret(spec) + val keyBytes = secretKey.encoded + android.util.Log.d("MessageCrypto", " - PBKDF2 key derived: ${keyBytes.size} bytes") + + // Generate random IV (16 bytes) + val iv = ByteArray(16) + java.security.SecureRandom().nextBytes(iv) + android.util.Log.d("MessageCrypto", " - IV generated: ${iv.size} bytes") + + // AES-CBC encryption + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val keySpec = SecretKeySpec(keyBytes, "AES") + val ivSpec = IvParameterSpec(iv) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) + val ciphertext = cipher.doFinal(compressed) + android.util.Log.d("MessageCrypto", " - Ciphertext size: ${ciphertext.size}") + + // Format: "ivBase64:ciphertextBase64" (same as RN new format) + val ivBase64 = Base64.encodeToString(iv, Base64.NO_WRAP) + val ctBase64 = Base64.encodeToString(ciphertext, Base64.NO_WRAP) + val result = "$ivBase64:$ctBase64" + android.util.Log.d("MessageCrypto", " ✅ Reply blob encrypted: ${result.take(50)}...") + + result + } catch (e: Exception) { + android.util.Log.e("MessageCrypto", "❌ Failed to encrypt reply blob", e) + // Fallback: return plaintext (for backwards compatibility) + replyJson + } + } + + /** + * 🔥 Расшифровывает reply blob из attachments (как в React Native) + * Формат входа: "ivBase64:ciphertextBase64" + */ + fun decryptReplyBlob(encryptedBlob: String, plainKeyAndNonce: ByteArray): String { + return try { + android.util.Log.d("MessageCrypto", "🔓 Decrypting reply blob...") + android.util.Log.d("MessageCrypto", " - EncryptedBlob length: ${encryptedBlob.length}") + android.util.Log.d("MessageCrypto", " - PlainKeyAndNonce length: ${plainKeyAndNonce.size}") + + // Check if it's encrypted format (contains ':') + if (!encryptedBlob.contains(':')) { + android.util.Log.d("MessageCrypto", " - Plain JSON detected, returning as-is") + return encryptedBlob + } + + // Parse ivBase64:ciphertextBase64 + val parts = encryptedBlob.split(':') + if (parts.size != 2) { + android.util.Log.e("MessageCrypto", " - Invalid format, expected iv:ciphertext") + return encryptedBlob + } + + val iv = Base64.decode(parts[0], Base64.DEFAULT) + val ciphertext = Base64.decode(parts[1], Base64.DEFAULT) + android.util.Log.d("MessageCrypto", " - IV size: ${iv.size}, Ciphertext size: ${ciphertext.size}") + + // Password from keyAndNonce + val password = String(plainKeyAndNonce, Charsets.UTF_8) + + // PBKDF2 key derivation + val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") + val spec = javax.crypto.spec.PBEKeySpec( + password.toCharArray(), + "rosetta".toByteArray(Charsets.UTF_8), + 1000, + 256 + ) + val secretKey = factory.generateSecret(spec) + val keyBytes = secretKey.encoded + + // AES-CBC decryption + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val keySpec = SecretKeySpec(keyBytes, "AES") + val ivSpec = IvParameterSpec(iv) + cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec) + val decompressed = cipher.doFinal(ciphertext) + + // Decompress with inflate + val inflater = java.util.zip.Inflater() + inflater.setInput(decompressed) + val outputBuffer = ByteArray(decompressed.size * 10) + val outputSize = inflater.inflate(outputBuffer) + inflater.end() + val plaintext = String(outputBuffer, 0, outputSize, Charsets.UTF_8) + + android.util.Log.d("MessageCrypto", " ✅ Reply blob decrypted: ${plaintext.take(50)}...") + plaintext + } catch (e: Exception) { + android.util.Log.e("MessageCrypto", "❌ Failed to decrypt reply blob: ${e.message}", e) + // Return as-is, might be plain JSON + encryptedBlob + } + } } // Extension functions для конвертации diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 117873b..7325e4c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -439,7 +439,7 @@ fun ChatDetailScreen( onClick = { // Копируем текст выбранных сообщений val textToCopy = messages - .filter { selectedMessages.contains(it.id) } + .filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") } .sortedBy { it.timestamp } .joinToString("\n\n") { msg -> val time = SimpleDateFormat("HH:mm", Locale.getDefault()) @@ -809,9 +809,9 @@ fun ChatDetailScreen( ) { // Reversed layout: item 0 = самое новое сообщение (внизу экрана) // messagesWithDates уже отсортирован новые->старые - // Используем id + timestamp для уникальности ключа (защита от пустых id) + // 🔥 Используем уникальный ключ: id + timestamp + index для гарантии уникальности itemsIndexed(messagesWithDates, key = { index, item -> - item.first.id.ifEmpty { "msg_${item.first.timestamp.time}_$index" } + "${item.first.id}_${item.first.timestamp.time}_$index" }) { index, (message, showDate) -> @@ -830,26 +830,28 @@ fun ChatDetailScreen( secondaryTextColor = secondaryTextColor ) } + // 🔥 Уникальный ключ для выделения: id + timestamp + val selectionKey = "${message.id}_${message.timestamp.time}" MessageBubble( message = message, isDarkTheme = isDarkTheme, showTail = showTail, - isSelected = selectedMessages.contains(message.id), + isSelected = selectedMessages.contains(selectionKey), onLongClick = { // Toggle selection on long press - selectedMessages = if (selectedMessages.contains(message.id)) { - selectedMessages - message.id + selectedMessages = if (selectedMessages.contains(selectionKey)) { + selectedMessages - selectionKey } else { - selectedMessages + message.id + selectedMessages + selectionKey } }, onClick = { // If in selection mode, toggle selection if (isSelectionMode) { - selectedMessages = if (selectedMessages.contains(message.id)) { - selectedMessages - message.id + selectedMessages = if (selectedMessages.contains(selectionKey)) { + selectedMessages - selectionKey } else { - selectedMessages + message.id + selectedMessages + selectionKey } } } @@ -1028,7 +1030,7 @@ fun ChatDetailScreen( .background(PrimaryBlue.copy(alpha = 0.1f)) .clickable { val selectedMsgs = messages - .filter { selectedMessages.contains(it.id) } + .filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") } .sortedBy { it.timestamp } viewModel.setReplyMessages(selectedMsgs) selectedMessages = emptySet() @@ -1064,7 +1066,7 @@ fun ChatDetailScreen( .background(PrimaryBlue.copy(alpha = 0.1f)) .clickable { val selectedMsgs = messages - .filter { selectedMessages.contains(it.id) } + .filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") } .sortedBy { it.timestamp } viewModel.setForwardMessages(selectedMsgs) selectedMessages = emptySet() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 6fd1012..258588e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -76,12 +76,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private val _inputText = MutableStateFlow("") val inputText: StateFlow = _inputText.asStateFlow() - // 🔥 Reply/Forward state + // 🔥 Reply/Forward state (как в React Native) data class ReplyMessage( val messageId: String, val text: String, val timestamp: Long, - val isOutgoing: Boolean + val isOutgoing: Boolean, + val publicKey: String = "" // publicKey отправителя цитируемого сообщения ) private val _replyMessages = MutableStateFlow>(emptyList()) val replyMessages: StateFlow> = _replyMessages.asStateFlow() @@ -200,19 +201,74 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val privateKey = myPrivateKey ?: return@launch ProtocolManager.addLog("📩 Incoming message: ${packet.messageId.take(8)}...") + ProtocolManager.addLog("📎 Attachments count: ${packet.attachments.size}") - // Расшифровываем в фоне - val decryptedText = MessageCrypto.decryptIncoming( + // Расшифровываем в фоне - получаем и текст и plainKeyAndNonce + val decryptResult = MessageCrypto.decryptIncomingFull( packet.content, packet.chachaKey, privateKey ) + val decryptedText = decryptResult.plaintext + val plainKeyAndNonce = decryptResult.plainKeyAndNonce // Кэшируем расшифрованный текст decryptionCache[packet.messageId] = decryptedText ProtocolManager.addLog("✅ Decrypted: ${decryptedText.take(20)}...") + // 🔥 Парсим reply из attachments (как в React Native) + var replyData: ReplyData? = null + val attachmentsJson = if (packet.attachments.isNotEmpty()) { + val jsonArray = JSONArray() + for (att in packet.attachments) { + ProtocolManager.addLog("📎 Attachment type: ${att.type}, blob size: ${att.blob.length}") + + // Если это MESSAGES (reply) - парсим и расшифровываем данные + var blobToStore = att.blob // По умолчанию сохраняем оригинальный blob + if (att.type == AttachmentType.MESSAGES && att.blob.isNotEmpty()) { + try { + // 🔥 Сначала расшифровываем blob (он зашифрован!) + val decryptedBlob = MessageCrypto.decryptReplyBlob(att.blob, plainKeyAndNonce) + ProtocolManager.addLog("📎 Decrypted reply blob: ${decryptedBlob.take(100)}") + + // 🔥 Сохраняем расшифрованный blob в БД + blobToStore = decryptedBlob + + // Парсим JSON массив с цитируемыми сообщениями + val replyArray = JSONArray(decryptedBlob) + if (replyArray.length() > 0) { + val firstReply = replyArray.getJSONObject(0) + val replyPublicKey = firstReply.optString("publicKey", "") + val replyText = firstReply.optString("message", "") + val replyMessageId = firstReply.optString("message_id", "") + + // Определяем автора цитаты + val isReplyFromMe = replyPublicKey == myPublicKey + + replyData = ReplyData( + messageId = replyMessageId, + senderName = if (isReplyFromMe) "You" else opponentTitle.ifEmpty { "User" }, + text = replyText, + isFromMe = isReplyFromMe + ) + ProtocolManager.addLog("✅ Parsed reply: from=${replyData?.senderName}, text=${replyText.take(30)}") + } + } catch (e: Exception) { + ProtocolManager.addLog("❌ Failed to parse reply: ${e.message}") + } + } + + jsonArray.put(JSONObject().apply { + put("id", att.id) + put("type", att.type.value) + put("preview", att.preview) + put("blob", blobToStore) // 🔥 Сохраняем расшифрованный blob для MESSAGES + }) + } + jsonArray.toString() + } else "[]" + // Обновляем UI в Main потоке withContext(Dispatchers.Main) { val message = ChatMessage( @@ -220,7 +276,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { text = decryptedText, isOutgoing = packet.fromPublicKey == myPublicKey, timestamp = Date(packet.timestamp), - status = MessageStatus.DELIVERED + status = MessageStatus.DELIVERED, + replyData = replyData // 🔥 Добавляем reply данные ) _messages.value = _messages.value + message } @@ -233,7 +290,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { encryptedKey = packet.chachaKey, timestamp = packet.timestamp, isFromMe = false, - delivered = 1 + delivered = 1, + attachmentsJson = attachmentsJson // 🔥 Сохраняем attachments ) // Обновляем диалог @@ -544,15 +602,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } /** - * 🔥 Установить сообщения для Reply + * 🔥 Установить сообщения для Reply (как в React Native) + * Сохраняем publicKey отправителя для правильного отображения цитаты */ fun setReplyMessages(messages: List) { + val sender = myPublicKey ?: "" + val opponent = opponentKey ?: "" + _replyMessages.value = messages.map { msg -> ReplyMessage( messageId = msg.id, text = msg.text, timestamp = msg.timestamp.time, - isOutgoing = msg.isOutgoing + isOutgoing = msg.isOutgoing, + // Если сообщение от меня - мой publicKey, иначе - собеседника + publicKey = if (msg.isOutgoing) sender else opponent ) } _isForwardMode.value = false @@ -563,12 +627,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { * 🔥 Установить сообщения для Forward */ fun setForwardMessages(messages: List) { + val sender = myPublicKey ?: "" + val opponent = opponentKey ?: "" + _replyMessages.value = messages.map { msg -> ReplyMessage( messageId = msg.id, text = msg.text, timestamp = msg.timestamp.time, - isOutgoing = msg.isOutgoing + isOutgoing = msg.isOutgoing, + publicKey = if (msg.isOutgoing) sender else opponent ) } _isForwardMode.value = true @@ -588,7 +656,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { * - Optimistic UI (мгновенное отображение) * - Шифрование в IO потоке * - Сохранение в БД в IO потоке - * - Поддержка Reply/Forward + * - Поддержка Reply/Forward через attachments (как в React Native) */ fun sendMessage() { Log.d(TAG, "🚀🚀🚀 sendMessage() CALLED 🚀🚀🚀") @@ -642,51 +710,86 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val messageId = UUID.randomUUID().toString().replace("-", "").take(32) val timestamp = System.currentTimeMillis() - // 🔥 Формируем текст с reply/forward - val fullText = buildString { - if (replyMsgs.isNotEmpty()) { - if (isForward) { - append("📨 Forwarded:\n") - } else { - append("↩️ Reply:\n") - } - replyMsgs.forEach { msg -> - append("\"${msg.text.take(100)}${if (msg.text.length > 100) "..." else ""}\"\n") - } - if (text.isNotEmpty()) { - append("\n") - } - } - append(text) - } + // 🔥 Формируем ReplyData для отображения в UI (только первое сообщение) + val replyData: ReplyData? = if (replyMsgs.isNotEmpty() && !isForward) { + val firstReply = replyMsgs.first() + ReplyData( + messageId = firstReply.messageId, + senderName = if (firstReply.isOutgoing) "You" else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }, + text = firstReply.text, + isFromMe = firstReply.isOutgoing + ) + } else null - // 1. 🚀 Optimistic UI - мгновенно показываем сообщение + // 1. 🚀 Optimistic UI - мгновенно показываем сообщение с reply bubble val optimisticMessage = ChatMessage( id = messageId, - text = fullText, + text = text, // Только основной текст, без prefix isOutgoing = true, timestamp = Date(timestamp), - status = MessageStatus.SENDING + status = MessageStatus.SENDING, + replyData = replyData // Данные для reply bubble ) _messages.value = _messages.value + optimisticMessage _inputText.value = "" + // Сохраняем reply для отправки + val replyMsgsToSend = replyMsgs.toList() + val isForwardToSend = isForward + // Очищаем reply после отправки clearReplyMessages() // Кэшируем текст - decryptionCache[messageId] = fullText + decryptionCache[messageId] = text - ProtocolManager.addLog("📤 Sending: \"${fullText.take(20)}...\"") + ProtocolManager.addLog("📤 Sending: \"${text.take(20)}...\" with ${replyMsgsToSend.size} reply attachments") // 2. 🔥 Шифрование и отправка в IO потоке viewModelScope.launch(Dispatchers.IO) { try { - // Шифрование (тяжёлая операция) - val (encryptedContent, encryptedKey) = MessageCrypto.encryptForSending(fullText, recipient) + // Шифрование текста - теперь возвращает EncryptedForSending с plainKeyAndNonce + val encryptResult = MessageCrypto.encryptForSending(text, recipient) + val encryptedContent = encryptResult.ciphertext + val encryptedKey = encryptResult.encryptedKey + val plainKeyAndNonce = encryptResult.plainKeyAndNonce // Для шифрования attachments val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) + // 🔥 Формируем attachments с reply (как в React Native) + val messageAttachments = mutableListOf() + var replyBlobPlaintext = "" // Сохраняем plaintext для БД + + if (replyMsgsToSend.isNotEmpty()) { + // Формируем JSON массив с цитируемыми сообщениями + val replyJsonArray = JSONArray() + replyMsgsToSend.forEach { msg -> + val replyJson = JSONObject().apply { + put("message_id", msg.messageId) + put("publicKey", msg.publicKey) + put("message", msg.text) + put("timestamp", msg.timestamp) + put("attachments", JSONArray()) // Пустой массив вложений + } + replyJsonArray.put(replyJson) + } + + replyBlobPlaintext = replyJsonArray.toString() // 🔥 Сохраняем plaintext + + // 🔥 Шифруем reply blob plainKeyAndNonce (как в React Native) + val encryptedReplyBlob = MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce) + + messageAttachments.add(MessageAttachment( + id = UUID.randomUUID().toString().replace("-", "").take(8), + blob = encryptedReplyBlob, + type = AttachmentType.MESSAGES, + preview = "" + )) + + ProtocolManager.addLog("📎 Reply attachment created: ${replyBlobPlaintext.take(50)}...") + ProtocolManager.addLog("📎 Encrypted reply blob: ${encryptedReplyBlob.take(50)}...") + } + val packet = PacketMessage().apply { fromPublicKey = sender toPublicKey = recipient @@ -695,7 +798,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { this.timestamp = timestamp this.privateKey = privateKeyHash this.messageId = messageId - attachments = emptyList() + attachments = messageAttachments } // Отправляем пакет @@ -706,18 +809,33 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { updateMessageStatus(messageId, MessageStatus.SENT) } - // 4. 💾 Сохранение в БД (уже в IO потоке) + // 4. 💾 Сохранение в БД с attachments (plaintext blob для MESSAGES) + val attachmentsJson = if (messageAttachments.isNotEmpty()) { + JSONArray().apply { + messageAttachments.forEach { att -> + put(JSONObject().apply { + put("id", att.id) + put("type", att.type.value) + put("preview", att.preview) + // 🔥 Для MESSAGES сохраняем plaintext, для остальных - как есть + put("blob", if (att.type == AttachmentType.MESSAGES) replyBlobPlaintext else att.blob) + }) + } + }.toString() + } else "[]" + saveMessageToDatabase( messageId = messageId, - text = fullText, + text = text, encryptedContent = encryptedContent, encryptedKey = encryptedKey, timestamp = timestamp, isFromMe = true, - delivered = 1 // SENT - сервер принял + delivered = 1, + attachmentsJson = attachmentsJson ) - saveDialog(fullText, timestamp) + saveDialog(text, timestamp) } catch (e: Exception) { Log.e(TAG, "Send error", e) @@ -773,7 +891,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { encryptedKey: String, timestamp: Long, isFromMe: Boolean, - delivered: Int = 0 + delivered: Int = 0, + attachmentsJson: String = "[]" ) { val account = myPublicKey ?: return val opponent = opponentKey ?: return @@ -793,7 +912,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { delivered = delivered, messageId = messageId, plainMessage = text, - attachments = "[]", + attachments = attachmentsJson, replyToMessageId = null, dialogKey = dialogKey ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 9aa2f27..5cf37b1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -33,6 +33,7 @@ import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.database.DialogEntity import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolState +import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.onboarding.PrimaryBlue import java.text.SimpleDateFormat import java.util.* @@ -655,12 +656,11 @@ fun ChatItem(chat: Chat, isDarkTheme: Boolean, onClick: () -> Unit) { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text( + // 🔥 Используем AppleEmojiText для отображения эмодзи + AppleEmojiText( text = chat.lastMessage, fontSize = 14.sp, color = secondaryTextColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) )