feat: Implement reply blob encryption and decryption, enhance message uniqueness in ChatDetailScreen, and utilize AppleEmojiText for emoji display

This commit is contained in:
2026-01-13 05:46:24 +05:00
parent b1a334c954
commit c52b6c1799
4 changed files with 342 additions and 61 deletions

View File

@@ -40,6 +40,14 @@ object MessageCrypto {
val nonce: String // Hex-encoded 24-byte nonce 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) * XChaCha20-Poly1305 шифрование (совместимо с @noble/ciphers в RN)
* *
@@ -531,10 +539,19 @@ object MessageCrypto {
return originalBytes 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<String, String> { fun encryptForSending(plaintext: String, recipientPublicKey: String): EncryptedForSending {
android.util.Log.d("MessageCrypto", "=".repeat(100)) android.util.Log.d("MessageCrypto", "=".repeat(100))
android.util.Log.d("MessageCrypto", "🚀🚀🚀 START ENCRYPTION FOR SENDING 🚀🚀🚀") android.util.Log.d("MessageCrypto", "🚀🚀🚀 START ENCRYPTION FOR SENDING 🚀🚀🚀")
android.util.Log.d("MessageCrypto", "=".repeat(100)) 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", " • Encrypted key: ${encryptedKey.take(60)}... (${encryptedKey.length} chars)")
android.util.Log.d("MessageCrypto", "=".repeat(100) + "\n") 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, ciphertext: String,
encryptedKey: String, encryptedKey: String,
myPrivateKey: String myPrivateKey: String
): String { ): DecryptedIncoming {
android.util.Log.d("MessageCrypto", "=".repeat(100)) android.util.Log.d("MessageCrypto", "=".repeat(100))
android.util.Log.d("MessageCrypto", "🔓🔓🔓 START DECRYPTION OF INCOMING MESSAGE 🔓🔓🔓") android.util.Log.d("MessageCrypto", "🔓🔓🔓 START DECRYPTION OF INCOMING MESSAGE 🔓🔓🔓")
android.util.Log.d("MessageCrypto", "=".repeat(100)) 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", "FINAL OUTPUT: '$plaintext'")
android.util.Log.d("MessageCrypto", "=".repeat(100) + "\n") 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 * Расшифровка MESSAGES attachment blob
* Формат: ivBase64:ciphertextBase64 * Формат: ivBase64:ciphertextBase64
@@ -719,6 +746,139 @@ object MessageCrypto {
null 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 для конвертации // Extension functions для конвертации

View File

@@ -439,7 +439,7 @@ fun ChatDetailScreen(
onClick = { onClick = {
// Копируем текст выбранных сообщений // Копируем текст выбранных сообщений
val textToCopy = messages val textToCopy = messages
.filter { selectedMessages.contains(it.id) } .filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
.sortedBy { it.timestamp } .sortedBy { it.timestamp }
.joinToString("\n\n") { msg -> .joinToString("\n\n") { msg ->
val time = SimpleDateFormat("HH:mm", Locale.getDefault()) val time = SimpleDateFormat("HH:mm", Locale.getDefault())
@@ -809,9 +809,9 @@ fun ChatDetailScreen(
) { ) {
// Reversed layout: item 0 = самое новое сообщение (внизу экрана) // Reversed layout: item 0 = самое новое сообщение (внизу экрана)
// messagesWithDates уже отсортирован новые->старые // messagesWithDates уже отсортирован новые->старые
// Используем id + timestamp для уникальности ключа (защита от пустых id) // 🔥 Используем уникальный ключ: id + timestamp + index для гарантии уникальности
itemsIndexed(messagesWithDates, key = { index, item -> itemsIndexed(messagesWithDates, key = { index, item ->
item.first.id.ifEmpty { "msg_${item.first.timestamp.time}_$index" } "${item.first.id}_${item.first.timestamp.time}_$index"
}) { }) {
index, index,
(message, showDate) -> (message, showDate) ->
@@ -830,26 +830,28 @@ fun ChatDetailScreen(
secondaryTextColor = secondaryTextColor secondaryTextColor = secondaryTextColor
) )
} }
// 🔥 Уникальный ключ для выделения: id + timestamp
val selectionKey = "${message.id}_${message.timestamp.time}"
MessageBubble( MessageBubble(
message = message, message = message,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
showTail = showTail, showTail = showTail,
isSelected = selectedMessages.contains(message.id), isSelected = selectedMessages.contains(selectionKey),
onLongClick = { onLongClick = {
// Toggle selection on long press // Toggle selection on long press
selectedMessages = if (selectedMessages.contains(message.id)) { selectedMessages = if (selectedMessages.contains(selectionKey)) {
selectedMessages - message.id selectedMessages - selectionKey
} else { } else {
selectedMessages + message.id selectedMessages + selectionKey
} }
}, },
onClick = { onClick = {
// If in selection mode, toggle selection // If in selection mode, toggle selection
if (isSelectionMode) { if (isSelectionMode) {
selectedMessages = if (selectedMessages.contains(message.id)) { selectedMessages = if (selectedMessages.contains(selectionKey)) {
selectedMessages - message.id selectedMessages - selectionKey
} else { } else {
selectedMessages + message.id selectedMessages + selectionKey
} }
} }
} }
@@ -1028,7 +1030,7 @@ fun ChatDetailScreen(
.background(PrimaryBlue.copy(alpha = 0.1f)) .background(PrimaryBlue.copy(alpha = 0.1f))
.clickable { .clickable {
val selectedMsgs = messages val selectedMsgs = messages
.filter { selectedMessages.contains(it.id) } .filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
.sortedBy { it.timestamp } .sortedBy { it.timestamp }
viewModel.setReplyMessages(selectedMsgs) viewModel.setReplyMessages(selectedMsgs)
selectedMessages = emptySet() selectedMessages = emptySet()
@@ -1064,7 +1066,7 @@ fun ChatDetailScreen(
.background(PrimaryBlue.copy(alpha = 0.1f)) .background(PrimaryBlue.copy(alpha = 0.1f))
.clickable { .clickable {
val selectedMsgs = messages val selectedMsgs = messages
.filter { selectedMessages.contains(it.id) } .filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
.sortedBy { it.timestamp } .sortedBy { it.timestamp }
viewModel.setForwardMessages(selectedMsgs) viewModel.setForwardMessages(selectedMsgs)
selectedMessages = emptySet() selectedMessages = emptySet()

View File

@@ -76,12 +76,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val _inputText = MutableStateFlow("") private val _inputText = MutableStateFlow("")
val inputText: StateFlow<String> = _inputText.asStateFlow() val inputText: StateFlow<String> = _inputText.asStateFlow()
// 🔥 Reply/Forward state // 🔥 Reply/Forward state (как в React Native)
data class ReplyMessage( data class ReplyMessage(
val messageId: String, val messageId: String,
val text: String, val text: String,
val timestamp: Long, val timestamp: Long,
val isOutgoing: Boolean val isOutgoing: Boolean,
val publicKey: String = "" // publicKey отправителя цитируемого сообщения
) )
private val _replyMessages = MutableStateFlow<List<ReplyMessage>>(emptyList()) private val _replyMessages = MutableStateFlow<List<ReplyMessage>>(emptyList())
val replyMessages: StateFlow<List<ReplyMessage>> = _replyMessages.asStateFlow() val replyMessages: StateFlow<List<ReplyMessage>> = _replyMessages.asStateFlow()
@@ -200,19 +201,74 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val privateKey = myPrivateKey ?: return@launch val privateKey = myPrivateKey ?: return@launch
ProtocolManager.addLog("📩 Incoming message: ${packet.messageId.take(8)}...") ProtocolManager.addLog("📩 Incoming message: ${packet.messageId.take(8)}...")
ProtocolManager.addLog("📎 Attachments count: ${packet.attachments.size}")
// Расшифровываем в фоне // Расшифровываем в фоне - получаем и текст и plainKeyAndNonce
val decryptedText = MessageCrypto.decryptIncoming( val decryptResult = MessageCrypto.decryptIncomingFull(
packet.content, packet.content,
packet.chachaKey, packet.chachaKey,
privateKey privateKey
) )
val decryptedText = decryptResult.plaintext
val plainKeyAndNonce = decryptResult.plainKeyAndNonce
// Кэшируем расшифрованный текст // Кэшируем расшифрованный текст
decryptionCache[packet.messageId] = decryptedText decryptionCache[packet.messageId] = decryptedText
ProtocolManager.addLog("✅ Decrypted: ${decryptedText.take(20)}...") 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 потоке // Обновляем UI в Main потоке
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val message = ChatMessage( val message = ChatMessage(
@@ -220,7 +276,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
text = decryptedText, text = decryptedText,
isOutgoing = packet.fromPublicKey == myPublicKey, isOutgoing = packet.fromPublicKey == myPublicKey,
timestamp = Date(packet.timestamp), timestamp = Date(packet.timestamp),
status = MessageStatus.DELIVERED status = MessageStatus.DELIVERED,
replyData = replyData // 🔥 Добавляем reply данные
) )
_messages.value = _messages.value + message _messages.value = _messages.value + message
} }
@@ -233,7 +290,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
encryptedKey = packet.chachaKey, encryptedKey = packet.chachaKey,
timestamp = packet.timestamp, timestamp = packet.timestamp,
isFromMe = false, 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<ChatMessage>) { fun setReplyMessages(messages: List<ChatMessage>) {
val sender = myPublicKey ?: ""
val opponent = opponentKey ?: ""
_replyMessages.value = messages.map { msg -> _replyMessages.value = messages.map { msg ->
ReplyMessage( ReplyMessage(
messageId = msg.id, messageId = msg.id,
text = msg.text, text = msg.text,
timestamp = msg.timestamp.time, timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing isOutgoing = msg.isOutgoing,
// Если сообщение от меня - мой publicKey, иначе - собеседника
publicKey = if (msg.isOutgoing) sender else opponent
) )
} }
_isForwardMode.value = false _isForwardMode.value = false
@@ -563,12 +627,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
* 🔥 Установить сообщения для Forward * 🔥 Установить сообщения для Forward
*/ */
fun setForwardMessages(messages: List<ChatMessage>) { fun setForwardMessages(messages: List<ChatMessage>) {
val sender = myPublicKey ?: ""
val opponent = opponentKey ?: ""
_replyMessages.value = messages.map { msg -> _replyMessages.value = messages.map { msg ->
ReplyMessage( ReplyMessage(
messageId = msg.id, messageId = msg.id,
text = msg.text, text = msg.text,
timestamp = msg.timestamp.time, timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing isOutgoing = msg.isOutgoing,
publicKey = if (msg.isOutgoing) sender else opponent
) )
} }
_isForwardMode.value = true _isForwardMode.value = true
@@ -588,7 +656,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
* - Optimistic UI (мгновенное отображение) * - Optimistic UI (мгновенное отображение)
* - Шифрование в IO потоке * - Шифрование в IO потоке
* - Сохранение в БД в IO потоке * - Сохранение в БД в IO потоке
* - Поддержка Reply/Forward * - Поддержка Reply/Forward через attachments (как в React Native)
*/ */
fun sendMessage() { fun sendMessage() {
Log.d(TAG, "🚀🚀🚀 sendMessage() CALLED 🚀🚀🚀") Log.d(TAG, "🚀🚀🚀 sendMessage() CALLED 🚀🚀🚀")
@@ -642,51 +710,86 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val messageId = UUID.randomUUID().toString().replace("-", "").take(32) val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
// 🔥 Формируем текст с reply/forward // 🔥 Формируем ReplyData для отображения в UI (только первое сообщение)
val fullText = buildString { val replyData: ReplyData? = if (replyMsgs.isNotEmpty() && !isForward) {
if (replyMsgs.isNotEmpty()) { val firstReply = replyMsgs.first()
if (isForward) { ReplyData(
append("📨 Forwarded:\n") messageId = firstReply.messageId,
} else { senderName = if (firstReply.isOutgoing) "You" else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } },
append("↩️ Reply:\n") text = firstReply.text,
} isFromMe = firstReply.isOutgoing
replyMsgs.forEach { msg -> )
append("\"${msg.text.take(100)}${if (msg.text.length > 100) "..." else ""}\"\n") } else null
}
if (text.isNotEmpty()) {
append("\n")
}
}
append(text)
}
// 1. 🚀 Optimistic UI - мгновенно показываем сообщение // 1. 🚀 Optimistic UI - мгновенно показываем сообщение с reply bubble
val optimisticMessage = ChatMessage( val optimisticMessage = ChatMessage(
id = messageId, id = messageId,
text = fullText, text = text, // Только основной текст, без prefix
isOutgoing = true, isOutgoing = true,
timestamp = Date(timestamp), timestamp = Date(timestamp),
status = MessageStatus.SENDING status = MessageStatus.SENDING,
replyData = replyData // Данные для reply bubble
) )
_messages.value = _messages.value + optimisticMessage _messages.value = _messages.value + optimisticMessage
_inputText.value = "" _inputText.value = ""
// Сохраняем reply для отправки
val replyMsgsToSend = replyMsgs.toList()
val isForwardToSend = isForward
// Очищаем reply после отправки // Очищаем reply после отправки
clearReplyMessages() 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 потоке // 2. 🔥 Шифрование и отправка в IO потоке
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
// Шифрование (тяжёлая операция) // Шифрование текста - теперь возвращает EncryptedForSending с plainKeyAndNonce
val (encryptedContent, encryptedKey) = MessageCrypto.encryptForSending(fullText, recipient) val encryptResult = MessageCrypto.encryptForSending(text, recipient)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce // Для шифрования attachments
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
// 🔥 Формируем attachments с reply (как в React Native)
val messageAttachments = mutableListOf<MessageAttachment>()
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 { val packet = PacketMessage().apply {
fromPublicKey = sender fromPublicKey = sender
toPublicKey = recipient toPublicKey = recipient
@@ -695,7 +798,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
this.timestamp = timestamp this.timestamp = timestamp
this.privateKey = privateKeyHash this.privateKey = privateKeyHash
this.messageId = messageId this.messageId = messageId
attachments = emptyList() attachments = messageAttachments
} }
// Отправляем пакет // Отправляем пакет
@@ -706,18 +809,33 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
updateMessageStatus(messageId, MessageStatus.SENT) 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( saveMessageToDatabase(
messageId = messageId, messageId = messageId,
text = fullText, text = text,
encryptedContent = encryptedContent, encryptedContent = encryptedContent,
encryptedKey = encryptedKey, encryptedKey = encryptedKey,
timestamp = timestamp, timestamp = timestamp,
isFromMe = true, isFromMe = true,
delivered = 1 // SENT - сервер принял delivered = 1,
attachmentsJson = attachmentsJson
) )
saveDialog(fullText, timestamp) saveDialog(text, timestamp)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Send error", e) Log.e(TAG, "Send error", e)
@@ -773,7 +891,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
encryptedKey: String, encryptedKey: String,
timestamp: Long, timestamp: Long,
isFromMe: Boolean, isFromMe: Boolean,
delivered: Int = 0 delivered: Int = 0,
attachmentsJson: String = "[]"
) { ) {
val account = myPublicKey ?: return val account = myPublicKey ?: return
val opponent = opponentKey ?: return val opponent = opponentKey ?: return
@@ -793,7 +912,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
delivered = delivered, delivered = delivered,
messageId = messageId, messageId = messageId,
plainMessage = text, plainMessage = text,
attachments = "[]", attachments = attachmentsJson,
replyToMessageId = null, replyToMessageId = null,
dialogKey = dialogKey dialogKey = dialogKey
) )

View File

@@ -33,6 +33,7 @@ import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.database.DialogEntity import com.rosetta.messenger.database.DialogEntity
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -655,12 +656,11 @@ fun ChatItem(chat: Chat, isDarkTheme: Boolean, onClick: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( // 🔥 Используем AppleEmojiText для отображения эмодзи
AppleEmojiText(
text = chat.lastMessage, text = chat.lastMessage,
fontSize = 14.sp, fontSize = 14.sp,
color = secondaryTextColor, color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )