feat: Enhance message handling and emoji picker
- Update MessageEntity to clarify encryption of plainMessage. - Introduce ERROR status in MessageStatus for handling message send failures. - Implement message delivery timeout logic in ChatDetailScreen. - Add retry and delete functionality for failed messages in ChatViewModel. - Improve message decryption process in ChatViewModel to handle various scenarios. - Refactor emoji categories in AppleEmojiPicker to align with Unicode standards and improve sorting.
This commit is contained in:
@@ -155,7 +155,21 @@ enum class MessageStatus {
|
||||
SENDING,
|
||||
SENT,
|
||||
DELIVERED,
|
||||
READ
|
||||
READ,
|
||||
ERROR // 🔥 Ошибка отправки (таймаут или реальная ошибка)
|
||||
}
|
||||
|
||||
// 🔥 Константа таймаута доставки (как в архиве - 80 секунд)
|
||||
private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L
|
||||
|
||||
/** Проверка: сообщение ещё может быть доставлено (не истёк таймаут) */
|
||||
private fun isMessageDeliveredByTime(timestamp: Long, attachmentsCount: Int = 0): Boolean {
|
||||
val maxTime = if (attachmentsCount > 0) {
|
||||
MESSAGE_MAX_TIME_TO_DELIVERED_MS * attachmentsCount
|
||||
} else {
|
||||
MESSAGE_MAX_TIME_TO_DELIVERED_MS
|
||||
}
|
||||
return System.currentTimeMillis() - timestamp < maxTime
|
||||
}
|
||||
|
||||
/** Получить текст даты (today, yesterday или полная дата) */
|
||||
@@ -895,6 +909,14 @@ fun ChatDetailScreen(
|
||||
onSwipeToReply = {
|
||||
// 🔥 Swipe-to-reply: добавляем это сообщение в reply
|
||||
viewModel.setReplyMessages(listOf(message))
|
||||
},
|
||||
onRetry = {
|
||||
// 🔥 Retry: удаляем старое и отправляем заново
|
||||
viewModel.retryMessage(message)
|
||||
},
|
||||
onDelete = {
|
||||
// 🔥 Delete: удаляем сообщение
|
||||
viewModel.deleteMessage(message.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1397,7 +1419,9 @@ private fun MessageBubble(
|
||||
isSelected: Boolean = false,
|
||||
onLongClick: () -> Unit = {},
|
||||
onClick: () -> Unit = {},
|
||||
onSwipeToReply: () -> Unit = {}
|
||||
onSwipeToReply: () -> Unit = {},
|
||||
onRetry: () -> Unit = {}, // 🔥 Retry для ошибки
|
||||
onDelete: () -> Unit = {} // 🔥 Delete для ошибки
|
||||
) {
|
||||
// 🔥 Swipe-to-reply state (как в Telegram)
|
||||
var swipeOffset by remember { mutableStateOf(0f) }
|
||||
@@ -1587,7 +1611,10 @@ private fun MessageBubble(
|
||||
Spacer(modifier = Modifier.width(3.dp))
|
||||
AnimatedMessageStatus(
|
||||
status = message.status,
|
||||
timeColor = timeColor
|
||||
timeColor = timeColor,
|
||||
timestamp = message.timestamp.time,
|
||||
onRetry = onRetry,
|
||||
onDelete = onDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1599,14 +1626,26 @@ private fun MessageBubble(
|
||||
|
||||
/**
|
||||
* 🎯 Анимированный статус сообщения с плавными переходами
|
||||
* Поддерживает ERROR статус с красной иконкой (как в архиве)
|
||||
*/
|
||||
@Composable
|
||||
private fun AnimatedMessageStatus(
|
||||
status: MessageStatus,
|
||||
timeColor: Color
|
||||
timeColor: Color,
|
||||
timestamp: Long = 0L,
|
||||
onRetry: () -> Unit = {},
|
||||
onDelete: () -> Unit = {}
|
||||
) {
|
||||
// 🔥 Проверяем таймаут для SENDING статуса
|
||||
val isTimedOut = status == MessageStatus.SENDING && timestamp > 0 && !isMessageDeliveredByTime(timestamp)
|
||||
val effectiveStatus = if (isTimedOut) MessageStatus.ERROR else status
|
||||
|
||||
// Цвет с анимацией
|
||||
val targetColor = if (status == MessageStatus.READ) Color(0xFF4FC3F7) else timeColor
|
||||
val targetColor = when (effectiveStatus) {
|
||||
MessageStatus.READ -> Color(0xFF4FC3F7) // Синий для прочитано
|
||||
MessageStatus.ERROR -> Color(0xFFE53935) // Красный для ошибки
|
||||
else -> timeColor
|
||||
}
|
||||
val animatedColor by animateColorAsState(
|
||||
targetValue = targetColor,
|
||||
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
|
||||
@@ -1614,13 +1653,13 @@ private fun AnimatedMessageStatus(
|
||||
)
|
||||
|
||||
// Анимация scale для эффекта "pop"
|
||||
var previousStatus by remember { mutableStateOf(status) }
|
||||
var previousStatus by remember { mutableStateOf(effectiveStatus) }
|
||||
var shouldAnimate by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(status) {
|
||||
if (previousStatus != status) {
|
||||
LaunchedEffect(effectiveStatus) {
|
||||
if (previousStatus != effectiveStatus) {
|
||||
shouldAnimate = true
|
||||
previousStatus = status
|
||||
previousStatus = effectiveStatus
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1634,25 +1673,63 @@ private fun AnimatedMessageStatus(
|
||||
label = "statusScale"
|
||||
)
|
||||
|
||||
// Crossfade для плавной смены иконки
|
||||
Crossfade(
|
||||
targetState = status,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "statusIcon"
|
||||
) { currentStatus ->
|
||||
Icon(
|
||||
imageVector = when (currentStatus) {
|
||||
MessageStatus.SENDING -> Icons.Default.Schedule
|
||||
MessageStatus.SENT -> Icons.Default.Done
|
||||
MessageStatus.DELIVERED -> Icons.Default.DoneAll
|
||||
MessageStatus.READ -> Icons.Default.DoneAll
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = animatedColor,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.scale(scale)
|
||||
)
|
||||
// 🔥 Для ошибки - показываем DropdownMenu
|
||||
var showErrorMenu by remember { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
// Crossfade для плавной смены иконки
|
||||
Crossfade(
|
||||
targetState = effectiveStatus,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "statusIcon"
|
||||
) { currentStatus ->
|
||||
Icon(
|
||||
imageVector = when (currentStatus) {
|
||||
MessageStatus.SENDING -> Icons.Default.Schedule // Часики - отправляется
|
||||
MessageStatus.SENT -> Icons.Default.Done // Одна галочка - отправлено
|
||||
MessageStatus.DELIVERED -> Icons.Default.Done // Одна галочка - доставлено
|
||||
MessageStatus.READ -> Icons.Default.DoneAll // Две галочки - прочитано
|
||||
MessageStatus.ERROR -> Icons.Default.Error // Ошибка - восклицательный знак
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = animatedColor,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.scale(scale)
|
||||
.then(
|
||||
if (currentStatus == MessageStatus.ERROR) {
|
||||
Modifier.clickable { showErrorMenu = true }
|
||||
} else Modifier
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 🔥 Меню ошибки (как в архиве)
|
||||
DropdownMenu(
|
||||
expanded = showErrorMenu,
|
||||
onDismissRequest = { showErrorMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Retry") },
|
||||
onClick = {
|
||||
showErrorMenu = false
|
||||
onRetry()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete", color = Color(0xFFE53935)) },
|
||||
onClick = {
|
||||
showErrorMenu = false
|
||||
onDelete()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Delete, contentDescription = null, tint = Color(0xFFE53935), modifier = Modifier.size(18.dp))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -298,6 +298,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// 🔥 Сохраняем в БД здесь (в ChatViewModel)
|
||||
// ProtocolManager.setupPacketHandlers() не вызывается, поэтому сохраняем сами
|
||||
// Используем fromPublicKey как opponent для корректного dialogKey
|
||||
val senderKey = packet.fromPublicKey
|
||||
saveMessageToDatabase(
|
||||
messageId = packet.messageId,
|
||||
text = decryptedText,
|
||||
@@ -306,11 +308,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
timestamp = packet.timestamp,
|
||||
isFromMe = false, // Это входящее сообщение
|
||||
delivered = DeliveryStatus.DELIVERED.value,
|
||||
attachmentsJson = attachmentsJson
|
||||
attachmentsJson = attachmentsJson,
|
||||
opponentPublicKey = senderKey
|
||||
)
|
||||
|
||||
// 🔥 Обновляем диалог
|
||||
updateDialog(opponentKey!!, decryptedText, packet.timestamp, incrementUnread = !isDialogActive)
|
||||
// 🔥 Обновляем диалог - используем senderKey
|
||||
updateDialog(senderKey, decryptedText, packet.timestamp, incrementUnread = !isDialogActive)
|
||||
|
||||
// 👁️ НЕ отправляем read receipt автоматически!
|
||||
// Read receipt отправляется только когда пользователь видит сообщение
|
||||
@@ -428,12 +431,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
hasMoreMessages = entities.size >= PAGE_SIZE
|
||||
currentOffset = entities.size
|
||||
|
||||
// 🔥 ОПТИМИЗАЦИЯ: Быстрая конвертация в одном проходе
|
||||
// 🔥 Расшифровка сообщений при загрузке (как в архиве)
|
||||
val messages = ArrayList<ChatMessage>(entities.size)
|
||||
for (entity in entities.asReversed()) {
|
||||
messages.add(entityToChatMessage(entity))
|
||||
val chatMsg = entityToChatMessage(entity)
|
||||
messages.add(chatMsg)
|
||||
}
|
||||
|
||||
ProtocolManager.addLog("📋 Decrypted and loaded ${messages.size} messages from DB")
|
||||
|
||||
// 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
_messages.value = messages
|
||||
@@ -495,7 +501,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
if (entities.isNotEmpty()) {
|
||||
val newMessages = entities.map { entity ->
|
||||
entityToChatMessage(entity)
|
||||
}.reversed()
|
||||
}.asReversed()
|
||||
|
||||
// Добавляем в начало списка (старые сообщения)
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -519,19 +525,56 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Быстрая конвертация Entity -> ChatMessage
|
||||
* 🔥 Конвертация Entity -> ChatMessage с расшифровкой из content + chachaKey
|
||||
* Как в архиве: расшифровываем при каждой загрузке
|
||||
*/
|
||||
private fun entityToChatMessage(entity: MessageEntity): ChatMessage {
|
||||
private suspend fun entityToChatMessage(entity: MessageEntity): ChatMessage {
|
||||
// Расшифровываем сообщение из content + chachaKey
|
||||
var displayText = try {
|
||||
val privateKey = myPrivateKey
|
||||
if (privateKey != null && entity.content.isNotEmpty() && entity.chachaKey.isNotEmpty()) {
|
||||
// Расшифровываем как в архиве: content + chachaKey + privateKey
|
||||
val decrypted = MessageCrypto.decryptIncoming(
|
||||
ciphertext = entity.content,
|
||||
encryptedKey = entity.chachaKey,
|
||||
myPrivateKey = privateKey
|
||||
)
|
||||
ProtocolManager.addLog("🔓 Decrypted from DB: ${decrypted.take(20)}...")
|
||||
decrypted
|
||||
} else {
|
||||
// Fallback на расшифровку plainMessage с приватным ключом
|
||||
if (privateKey != null && entity.plainMessage.isNotEmpty()) {
|
||||
try {
|
||||
CryptoManager.decryptWithPassword(entity.plainMessage, privateKey) ?: entity.plainMessage
|
||||
} catch (e: Exception) {
|
||||
ProtocolManager.addLog("⚠️ plainMessage decrypt error: ${e.message}")
|
||||
entity.plainMessage
|
||||
}
|
||||
} else {
|
||||
entity.plainMessage
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ProtocolManager.addLog("❌ Decrypt error: ${e.message}, trying plainMessage")
|
||||
// Пробуем расшифровать plainMessage
|
||||
val privateKey = myPrivateKey
|
||||
if (privateKey != null && entity.plainMessage.isNotEmpty()) {
|
||||
try {
|
||||
CryptoManager.decryptWithPassword(entity.plainMessage, privateKey) ?: entity.plainMessage
|
||||
} catch (e2: Exception) {
|
||||
entity.plainMessage
|
||||
}
|
||||
} else {
|
||||
entity.plainMessage
|
||||
}
|
||||
}
|
||||
|
||||
// Парсим attachments для поиска MESSAGES (цитата)
|
||||
var replyData = parseReplyFromAttachments(entity.attachments, entity.fromMe == 1)
|
||||
|
||||
// Текст сообщения и возможный fallback reply из текста
|
||||
var displayText = entity.plainMessage
|
||||
|
||||
// Если не нашли reply в attachments, пробуем распарсить из текста
|
||||
// Формат: "🇵 Reply: "текст цитаты"\n\nоснователь текст" или подобный
|
||||
if (replyData == null) {
|
||||
val parseResult = parseReplyFromText(entity.plainMessage)
|
||||
val parseResult = parseReplyFromText(displayText)
|
||||
if (parseResult != null) {
|
||||
replyData = parseResult.first
|
||||
displayText = parseResult.second
|
||||
@@ -540,13 +583,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
return ChatMessage(
|
||||
id = entity.messageId,
|
||||
text = displayText, // Уже расшифровано при сохранении
|
||||
text = displayText,
|
||||
isOutgoing = entity.fromMe == 1,
|
||||
timestamp = Date(entity.timestamp),
|
||||
status = when (entity.delivered) {
|
||||
0 -> MessageStatus.SENDING
|
||||
1 -> MessageStatus.DELIVERED
|
||||
2 -> MessageStatus.SENT // Changed from ERROR to SENT
|
||||
2 -> MessageStatus.SENT
|
||||
3 -> MessageStatus.READ
|
||||
else -> MessageStatus.SENT
|
||||
},
|
||||
@@ -693,6 +736,41 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_replyMessages.value = emptyList()
|
||||
_isForwardMode.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Удалить сообщение (для ошибки отправки)
|
||||
*/
|
||||
fun deleteMessage(messageId: String) {
|
||||
viewModelScope.launch {
|
||||
// Удаляем из UI
|
||||
_messages.value = _messages.value.filter { it.id != messageId }
|
||||
|
||||
// Удаляем из БД
|
||||
val account = myPublicKey ?: return@launch
|
||||
withContext(Dispatchers.IO) {
|
||||
messageDao.deleteMessage(account, messageId)
|
||||
}
|
||||
ProtocolManager.addLog("🗑️ Message deleted: ${messageId.take(8)}...")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Повторить отправку сообщения (для ошибки)
|
||||
*/
|
||||
fun retryMessage(message: ChatMessage) {
|
||||
viewModelScope.launch {
|
||||
// Удаляем старое сообщение
|
||||
deleteMessage(message.id)
|
||||
|
||||
// Устанавливаем текст в инпут и отправляем
|
||||
_inputText.value = message.text
|
||||
|
||||
// Небольшая задержка чтобы UI обновился
|
||||
delay(100)
|
||||
sendMessage()
|
||||
ProtocolManager.addLog("🔄 Retrying message: ${message.text.take(20)}...")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 Оптимизированная отправка сообщения
|
||||
@@ -881,7 +959,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
encryptedKey = encryptedKey,
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered = 1,
|
||||
delivered = 0, // 🔥 SENDING - ждём PacketDelivery для DELIVERED
|
||||
attachmentsJson = attachmentsJson
|
||||
)
|
||||
|
||||
@@ -971,6 +1049,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
/**
|
||||
* Сохранить сообщение в базу данных
|
||||
* 🔒 Безопасность: plainMessage НЕ сохраняется - только зашифрованный content + chachaKey
|
||||
* @param text - расшифрованный текст (используется только для логов и обновления диалога)
|
||||
* @param opponentPublicKey - публичный ключ собеседника (используется вместо глобального opponentKey)
|
||||
*/
|
||||
private suspend fun saveMessageToDatabase(
|
||||
messageId: String,
|
||||
@@ -980,14 +1061,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
timestamp: Long,
|
||||
isFromMe: Boolean,
|
||||
delivered: Int = 0,
|
||||
attachmentsJson: String = "[]"
|
||||
attachmentsJson: String = "[]",
|
||||
opponentPublicKey: String? = null
|
||||
) {
|
||||
val account = myPublicKey ?: return
|
||||
val opponent = opponentKey ?: return
|
||||
val opponent = opponentPublicKey ?: opponentKey ?: return
|
||||
val privateKey = this.privateKey ?: return
|
||||
|
||||
try {
|
||||
val dialogKey = getDialogKey(account, opponent)
|
||||
|
||||
// 🔒 Шифруем plainMessage с использованием приватного ключа
|
||||
val encryptedPlainMessage = CryptoManager.encryptWithPassword(text, privateKey)
|
||||
|
||||
// Проверяем существует ли сообщение
|
||||
val exists = messageDao.messageExists(account, messageId)
|
||||
ProtocolManager.addLog("💾 Saving message to DB:")
|
||||
@@ -1007,7 +1093,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fromMe = if (isFromMe) 1 else 0,
|
||||
delivered = delivered,
|
||||
messageId = messageId,
|
||||
plainMessage = text,
|
||||
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст для хранения в БД
|
||||
attachments = attachmentsJson,
|
||||
replyToMessageId = null,
|
||||
dialogKey = dialogKey
|
||||
|
||||
Reference in New Issue
Block a user