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
|
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 для конвертации
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user