feat: Implement reply blob encryption and decryption, enhance message uniqueness in ChatDetailScreen, and utilize AppleEmojiText for emoji display
This commit is contained in:
@@ -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<String, String> {
|
||||
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 для конвертации
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -76,12 +76,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val _inputText = MutableStateFlow("")
|
||||
val inputText: StateFlow<String> = _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<List<ReplyMessage>>(emptyList())
|
||||
val replyMessages: StateFlow<List<ReplyMessage>> = _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<ChatMessage>) {
|
||||
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<ChatMessage>) {
|
||||
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<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 {
|
||||
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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user