feat: Implement reply blob encryption and decryption, enhance message uniqueness in ChatDetailScreen, and utilize AppleEmojiText for emoji display
This commit is contained in:
@@ -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