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

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

View File

@@ -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()

View File

@@ -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
)

View File

@@ -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)
)