From 42b1cdd79aeaed13e26aab8001e4989b5f877fa5 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 13 Jan 2026 16:05:42 +0500 Subject: [PATCH] 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. --- .../messenger/data/MessageRepository.kt | 49 ++- .../messenger/database/MessageEntities.kt | 2 +- .../messenger/ui/chats/ChatDetailScreen.kt | 133 +++++-- .../messenger/ui/chats/ChatViewModel.kt | 124 +++++- .../ui/components/AppleEmojiPicker.kt | 356 +++++++++++++++--- 5 files changed, 551 insertions(+), 113 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 17cd0e5..b864bfd 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -157,6 +157,9 @@ class MessageRepository private constructor(private val context: Context) { // Сериализуем attachments в JSON val attachmentsJson = serializeAttachments(attachments) + // 🔒 Шифруем plainMessage с использованием приватного ключа + val encryptedPlainMessage = CryptoManager.encryptWithPassword(text.trim(), privateKey) + // Сохраняем в БД val entity = MessageEntity( account = account, @@ -169,7 +172,7 @@ class MessageRepository private constructor(private val context: Context) { fromMe = 1, delivered = DeliveryStatus.WAITING.value, messageId = messageId, - plainMessage = text.trim(), + plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст attachments = attachmentsJson, replyToMessageId = replyToMessageId, dialogKey = dialogKey @@ -230,6 +233,9 @@ class MessageRepository private constructor(private val context: Context) { privateKey ) + // 🔒 Шифруем plainMessage с использованием приватного ключа + val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey) + // Сохраняем в БД val entity = MessageEntity( account = account, @@ -242,7 +248,7 @@ class MessageRepository private constructor(private val context: Context) { fromMe = 0, delivered = DeliveryStatus.DELIVERED.value, messageId = packet.messageId, - plainMessage = plainText, + plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст attachments = attachmentsJson, dialogKey = dialogKey ) @@ -405,18 +411,33 @@ class MessageRepository private constructor(private val context: Context) { } // Extension functions - private fun MessageEntity.toMessage() = Message( - id = id, - messageId = messageId, - fromPublicKey = fromPublicKey, - toPublicKey = toPublicKey, - content = plainMessage, - timestamp = timestamp, - isFromMe = fromMe == 1, - isRead = read == 1, - deliveryStatus = DeliveryStatus.fromInt(delivered), - replyToMessageId = replyToMessageId - ) + private fun MessageEntity.toMessage(): Message { + // 🔓 Расшифровываем plainMessage с использованием приватного ключа + val privateKey = currentPrivateKey + val decryptedText = if (privateKey != null && plainMessage.isNotEmpty()) { + try { + CryptoManager.decryptWithPassword(plainMessage, privateKey) ?: plainMessage + } catch (e: Exception) { + android.util.Log.e("MessageRepository", "Failed to decrypt plainMessage: ${e.message}") + plainMessage // Fallback на зашифрованный текст если расшифровка не удалась + } + } else { + plainMessage + } + + return Message( + id = id, + messageId = messageId, + fromPublicKey = fromPublicKey, + toPublicKey = toPublicKey, + content = decryptedText, // 🔓 Расшифрованный текст + timestamp = timestamp, + isFromMe = fromMe == 1, + isRead = read == 1, + deliveryStatus = DeliveryStatus.fromInt(delivered), + replyToMessageId = replyToMessageId + ) + } private fun DialogEntity.toDialog() = Dialog( opponentKey = opponentKey, diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index 04d421f..135bb1f 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -49,7 +49,7 @@ data class MessageEntity( val messageId: String, // UUID сообщения @ColumnInfo(name = "plain_message") - val plainMessage: String, // Расшифрованный текст (для быстрого доступа) + val plainMessage: String, // 🔒 Зашифрованный текст (encryptWithPassword) для хранения в БД @ColumnInfo(name = "attachments") val attachments: String = "[]", // JSON массив вложений diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 3cbb0d5..df0bde3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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)) + } + ) + } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 9be8893..fbb0ad0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -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(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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiPicker.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiPicker.kt index 7c426b2..73ad67d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiPicker.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiPicker.kt @@ -53,81 +53,303 @@ data class EmojiCategory( val ranges: List> ) -// Порядок категорий +// Unicode Standard Emoji Ordering (v15.0) +// Порядок категорий согласно Unicode CLDR val EMOJI_CATEGORIES = listOf( - // 😀 Smileys & Emotion + // 😀 Smileys & Emotion (Unicode ordering) EmojiCategory("Smileys", "Смайлы", Icons.Default.SentimentSatisfied, listOf( - 0x1F600 to 0x1F64F, - 0x1F910 to 0x1F92F, - 0x1F970 to 0x1F9FF, - 0x263A to 0x263A, - 0x2639 to 0x2639 + // Face-Smiling + 0x1F600 to 0x1F60F, // 😀-😏 + // Face-Affection + 0x1F617 to 0x1F61D, // 😗-😝 + 0x1F970 to 0x1F979, // 🥰-🥹 + // Face-Tongue + 0x1F60B to 0x1F60D, // 😋-😍 + 0x1F61B to 0x1F61C, // 😛-😜 + // Face-Hand + 0x1F917 to 0x1F917, // 🤗 + 0x1F92D to 0x1F92F, // 🤭-🤯 + 0x1FAE1 to 0x1FAE3, // 🫡-🫣 + // Face-Neutral-Skeptical + 0x1F610 to 0x1F615, // 😐-😕 + 0x1F636 to 0x1F636, // 😶 + 0x1FAE4 to 0x1FAE8, // 🫤-🫨 + 0x1F644 to 0x1F644, // 🙄 + // Face-Sleepy + 0x1F62A to 0x1F62C, // 😪-😬 + 0x1F634 to 0x1F634, // 😴 + // Face-Unwell + 0x1F637 to 0x1F637, // 😷 + 0x1F912 to 0x1F915, // 🤒-🤕 + 0x1F922 to 0x1F92B, // 🤢-🤫 + 0x1F975 to 0x1F976, // 🥵-🥶 + // Face-Hat + 0x1F920 to 0x1F921, // 🤠-🤡 + // Face-Glasses + 0x1F913 to 0x1F913, // 🤓 + 0x1F60E to 0x1F60E, // 😎 + 0x1F978 to 0x1F978, // 🥸 + // Face-Concerned + 0x1F61E to 0x1F629, // 😞-😩 + 0x1F62D to 0x1F62D, // 😭 + 0x1F630 to 0x1F633, // 😰-😳 + 0x1F641 to 0x1F643, // 🙁-🙃 + 0x1F97A to 0x1F97A, // 🥺 + // Face-Negative + 0x1F616 to 0x1F616, // 😖 + 0x1F62E to 0x1F62F, // 😮-😯 + 0x1F635 to 0x1F635, // 😵 + 0x263A to 0x263A, // ☺ + 0x2639 to 0x2639 // ☹ )), - // 👋 People & Body + // 👋 People & Body (Hands, Body parts, Persons) EmojiCategory("People", "Люди", Icons.Default.Person, listOf( - 0x1F466 to 0x1F4FF, - 0x1F9D0 to 0x1F9DF, - 0x270A to 0x270D, - 0x261D to 0x261D, - 0x1F440 to 0x1F465 + // Hand-Fingers-Open + 0x1F44B to 0x1F44D, // 👋-👍 + 0x1FAF0 to 0x1FAF8, // 🫰-🫸 + // Hand-Fingers-Partial + 0x1F44E to 0x1F44F, // 👎-👏 + 0x1F91D to 0x1F91F, // 🤝-🤟 + 0x1F918 to 0x1F91C, // 🤘-🤜 + // Hand-Single-Finger + 0x261D to 0x261D, // ☝ + 0x1F446 to 0x1F44A, // 👆-👊 + // Hand-Fingers-Closed + 0x270A to 0x270D, // ✊-✍ + 0x1F450 to 0x1F450, // 👐 + 0x1F64C to 0x1F64F, // 🙌-🙏 + // Body-Parts + 0x1F440 to 0x1F445, // 👀-👅 + 0x1FAC0 to 0x1FAC5, // 🫀-🫅 + 0x1F9B4 to 0x1F9BF, // 🦴-🦿 + // Person + 0x1F466 to 0x1F469, // 👦-👩 + 0x1F9D1 to 0x1F9DD, // 🧑-🧝 + // Person-Gesture + 0x1F645 to 0x1F647, // 🙅-🙇 + 0x1F64B to 0x1F64B, // 🙋 + 0x1F926 to 0x1F926, // 🤦 + 0x1F937 to 0x1F937, // 🤷 + // Person-Role, Person-Activity + 0x1F46A to 0x1F46F, // 👪-👯 + 0x1F470 to 0x1F487, // 👰-💇 + 0x1F6B4 to 0x1F6B6, // 🚴-🚶 + 0x1F9CE to 0x1F9CF // 🧎-🧏 )), // 🐱 Animals & Nature EmojiCategory("Animals", "Животные", Icons.Default.Pets, listOf( - 0x1F400 to 0x1F43F, - 0x1F980 to 0x1F9AE, - 0x1F330 to 0x1F335, - 0x1F337 to 0x1F34F, - 0x2618 to 0x2618 + // Animal-Mammal + 0x1F435 to 0x1F43E, // 🐵-🐾 + 0x1F9A0 to 0x1F9AE, // 🦠-🦮 + 0x1F981 to 0x1F99F, // 🦁-🦟 + // Animal-Bird + 0x1F413 to 0x1F414, // 🐓-🐔 + 0x1F423 to 0x1F427, // 🐣-🐧 + 0x1F54A to 0x1F54A, // 🕊 + 0x1F983 to 0x1F987, // 🦃-🦇 + 0x1F99A to 0x1F99C, // 🦚-🦜 + 0x1FABD to 0x1FABF, // 🪽-🪿 + // Animal-Amphibian, Reptile + 0x1F40D to 0x1F40E, // 🐍-🐎 + 0x1F428 to 0x1F42D, // 🐨-🐭 + 0x1F422 to 0x1F422, // 🐢 + 0x1F98E to 0x1F998, // 🦎-🦘 + // Animal-Marine + 0x1F419 to 0x1F421, // 🐙-🐡 + 0x1F988 to 0x1F98D, // 🦈-🦍 + 0x1FAB4 to 0x1FABC, // 🪴-🪼 + // Animal-Bug + 0x1F40C to 0x1F40C, // 🐌 + 0x1F41B to 0x1F41F, // 🐛-🐟 + 0x1F577 to 0x1F578, // 🕷-🕸 + 0x1F997 to 0x1F997, // 🦗 + 0x1FAB0 to 0x1FAB3, // 🪰-🪳 + // Plant-Flower + 0x1F337 to 0x1F340, // 🌷-🍀 + 0x1F490 to 0x1F490, // 💐 + 0x1FAB7 to 0x1FABB, // 🪷-🪻 + // Plant-Other + 0x1F330 to 0x1F336, // 🌰-🌶 + 0x1F341 to 0x1F344, // 🍁-🍄 + 0x2618 to 0x2618 // ☘ )), // 🍎 Food & Drink EmojiCategory("Food", "Еда", Icons.Default.Restaurant, listOf( - 0x1F345 to 0x1F37F, - 0x1F950 to 0x1F96F, - 0x1F9C0 to 0x1F9CB, - 0x1FAD0 to 0x1FAD9, - 0x2615 to 0x2615 + // Food-Fruit + 0x1F347 to 0x1F353, // 🍇-🍓 + 0x1FAD0 to 0x1FAD4, // 🫐-🫔 + 0x1F95D to 0x1F95D, // 🥝 + // Food-Vegetable + 0x1F345 to 0x1F346, // 🍅-🍆 + 0x1F951 to 0x1F95C, // 🥑-🥜 + 0x1F96C to 0x1F96F, // 🥬-🥯 + 0x1FAD5 to 0x1FAD8, // 🫕-🫘 + // Food-Prepared + 0x1F354 to 0x1F37B, // 🍔-🍻 + 0x1F95E to 0x1F96B, // 🥞-🥫 + 0x1F9C0 to 0x1F9CB, // 🧀-🧋 + 0x1FAD9 to 0x1FADB, // 🫙-🫛 + // Drink + 0x1F37C to 0x1F37F, // 🍼-🍿 + 0x2615 to 0x2615, // ☕ + 0x1F9C3 to 0x1F9C9 // 🧃-🧉 )), // ✈️ Travel & Places EmojiCategory("Travel", "Места", Icons.Default.Flight, listOf( - 0x1F680 to 0x1F6FF, - 0x1F3D4 to 0x1F3DF, - 0x1F3E0 to 0x1F3F0, - 0x2708 to 0x2708, - 0x26F0 to 0x26FF + // Place-Map + 0x1F30D to 0x1F310, // 🌍-🌐 + 0x1F5FA to 0x1F5FA, // 🗺 + // Place-Geographic + 0x26F0 to 0x26F1, // ⛰-⛱ + 0x1F3D4 to 0x1F3DC, // 🏔-🏜 + 0x1F30B to 0x1F30C, // 🌋-🌌 + // Place-Building + 0x1F3D7 to 0x1F3DB, // 🏗-🏛 + 0x1F3DD to 0x1F3DF, // 🏝-🏟 + 0x1F3E0 to 0x1F3F0, // 🏠-🏰 + // Place-Religious + 0x26EA to 0x26EA, // ⛪ + 0x1F54C to 0x1F54D, // 🕌-🕍 + // Place-Other + 0x26F2 to 0x26F5, // ⛲-⛵ + 0x1F5FC to 0x1F5FF, // 🗼-🗿 + // Transport-Ground + 0x1F680 to 0x1F6A0, // 🚀-🚠 + 0x1F6A1 to 0x1F6C5, // 🚡-🛅 + 0x1F6F0 to 0x1F6FF, // 🛰-🛿 + 0x1F68A to 0x1F68F, // 🚊-🚏 + 0x2708 to 0x2708 // ✈ )), // ⚽ Activities EmojiCategory("Activities", "Спорт", Icons.Default.SportsSoccer, listOf( - 0x1F3A0 to 0x1F3CA, - 0x1F3CB to 0x1F3D3, - 0x1F93C to 0x1F94F, - 0x26BD to 0x26BE, - 0x265F to 0x2660, - 0x1F9E0 to 0x1F9FF + // Event + 0x1F380 to 0x1F393, // 🎀-🎓 + 0x1F9E7 to 0x1F9E7, // 🧧 + // Award-Medal + 0x1F396 to 0x1F397, // 🎖-🎗 + 0x1F3C5 to 0x1F3C6, // 🏅-🏆 + // Sport + 0x26BD to 0x26BE, // ⚽-⚾ + 0x1F3C8 to 0x1F3D3, // 🏈-🏓 + 0x1F93C to 0x1F94F, // 🤼-🥏 + 0x1F945 to 0x1F945, // 🥅 + // Game + 0x1F3A0 to 0x1F3C4, // 🎠-🏄 + 0x1F3CB to 0x1F3CB, // 🏋 + 0x1F3CC to 0x1F3CF, // 🏌-🏏 + 0x265F to 0x2660, // ♟-♠ + 0x1F0CF to 0x1F0CF, // 🃏 + 0x1FA80 to 0x1FA88, // 🪀-🪈 + // Arts & Crafts + 0x1F3A4 to 0x1F3B4, // 🎤-🎴 + 0x1F3B5 to 0x1F3BE, // 🎵-🎾 + 0x1FA94 to 0x1FA9F, // 🪔-🪟 + 0x1F9F6 to 0x1F9FF // 🧶-🧿 )), // 💡 Objects EmojiCategory("Objects", "Объекты", Icons.Default.Lightbulb, listOf( - 0x1F4A1 to 0x1F4FF, - 0x1F500 to 0x1F5FF, - 0x1F6E0 to 0x1F6EF, - 0x1FA70 to 0x1FAFF, - 0x2328 to 0x2328 + // Clothing + 0x1F451 to 0x1F462, // 👑-👢 + 0x1F97B to 0x1F97F, // 🥻-🥿 + 0x1FA70 to 0x1FA7C, // 🩰-🩼 + // Sound + 0x1F507 to 0x1F50E, // 🔇-🔎 + 0x1F4E2 to 0x1F4E3, // 📢-📣 + // Music + 0x1F3B7 to 0x1F3BB, // 🎷-🎻 + 0x1FA95 to 0x1FA98, // 🪕-🪘 + // Phone + 0x1F4F1 to 0x1F4F5, // 📱-📵 + // Computer + 0x1F4BB to 0x1F4BF, // 💻-💿 + 0x1F5A5 to 0x1F5B2, // 🖥-🖲 + 0x2328 to 0x2328, // ⌨ + // Light & Video + 0x1F4A1 to 0x1F4A1, // 💡 + 0x1F4F7 to 0x1F4FD, // 📷-📽 + 0x1F50B to 0x1F50D, // 🔋-🔍 + 0x1F56F to 0x1F570, // 🕯-🕰 + // Book-Paper + 0x1F4D0 to 0x1F4DA, // 📐-📚 + 0x1F4DC to 0x1F4E1, // 📜-📡 + // Money + 0x1F4B0 to 0x1F4BA, // 💰-💺 + // Mail + 0x1F4E4 to 0x1F4F0, // 📤-📰 + // Writing + 0x270F to 0x270F, // ✏ + 0x1F4DD to 0x1F4DF, // 📝-📟 + 0x1F58A to 0x1F58D, // 🖊-🖍 + // Office + 0x1F4C0 to 0x1F4CF, // 💀-📏 + 0x1F4DB to 0x1F4DB, // 📛 + // Lock + 0x1F50F to 0x1F513, // 🔏-🔓 + // Tool + 0x1F527 to 0x1F52F, // 🔧-🔯 + 0x1F5DC to 0x1F5E3, // 🗜-🗣 + 0x1F6E0 to 0x1F6E3, // 🛠-🛣 + 0x1FA9A to 0x1FAAC, // 🪚-🪬 + // Household + 0x1F6BD to 0x1F6BF, // 🚽-🚿 + 0x1F6C1 to 0x1F6C1, // 🛁 + 0x1F9F4 to 0x1F9F5, // 🧴-🧵 + 0x1F9F0 to 0x1F9F3, // 🧰-🧳 + 0x1FAA0 to 0x1FAA8, // 🪠-🪨 + 0x1FAAA to 0x1FAAC, // 🪪-🪬 + // Other-Object + 0x1F5DE to 0x1F5DE, // 🗞 + 0x1F4FF to 0x1F4FF // 📿 )), // ❤️ Symbols EmojiCategory("Symbols", "Символы", Icons.Default.Favorite, listOf( - 0x2764 to 0x2764, - 0x1F490 to 0x1F49F, - 0x2600 to 0x26FF, - 0x2700 to 0x27BF, - 0x1F170 to 0x1F1FF, - 0x00A9 to 0x00AE, - 0x203C to 0x3299 + // Heart + 0x2764 to 0x2764, // ❤ + 0x1F493 to 0x1F49F, // 💓-💟 + 0x1F90D to 0x1F90F, // 🤍-🤏 + 0x2763 to 0x2763, // ❣ + 0x1FA75 to 0x1FA77, // 🩵-🩷 + // Emotion + 0x1F4A2 to 0x1F4AF, // 💢-💯 + 0x1F573 to 0x1F576, // 🕳-🕶 + // Zodiac + 0x2648 to 0x2653, // ♈-♓ + // Av-Symbol + 0x1F500 to 0x1F506, // 🔀-🔆 + 0x1F514 to 0x1F526, // 🔔-🔦 + 0x1F530 to 0x1F53D, // 🔰-🔽 + // Geometric + 0x26AA to 0x26AB, // ⚪-⚫ + 0x1F534 to 0x1F53A, // 🔴-🔺 + // Other-Symbol + 0x2600 to 0x2604, // ☀-☄ + 0x2614 to 0x2615, // ☔-☕ + 0x267F to 0x267F, // ♿ + 0x2695 to 0x269C, // ⚕-⚜ + 0x2700 to 0x27BF, // ✀-➿ + 0x00A9 to 0x00AE, // ©-® + 0x203C to 0x203C, // ‼ + 0x2049 to 0x2049, // ⁉ + 0x2122 to 0x2122, // ™ + 0x2139 to 0x2139, // ℹ + 0x2194 to 0x21AA, // ↔-↪ + 0x231A to 0x231B, // ⌚-⌛ + 0x23E9 to 0x23F3, // ⏩-⏳ + 0x23F8 to 0x23FA, // ⏸-⏺ + 0x25AA to 0x25FE, // ▪-◾ + 0x2611 to 0x2612, // ☑-☒ + 0x2714 to 0x2716, // ✔-✖ + 0x274C to 0x274E, // ❌-❎ + 0x2753 to 0x2757 // ❓-❗ )), // 🏳️ Flags EmojiCategory("Flags", "Флаги", Icons.Default.Flag, listOf( - 0x1F1E0 to 0x1F1FF, - 0x1F3F3 to 0x1F3F4, - 0x1F3C1 to 0x1F3C1, - 0x1F6A9 to 0x1F6A9 + // Flag + 0x1F3C1 to 0x1F3C1, // 🏁 + 0x1F6A9 to 0x1F6A9, // 🚩 + 0x1F3F3 to 0x1F3F4, // 🏳-🏴 + // Country-Flag (Regional Indicator Symbols) + 0x1F1E0 to 0x1F1FF // 🇦-🇿 (country flags) )) ) @@ -193,28 +415,60 @@ object EmojiCache { return emojisByCategory?.get(categoryKey) ?: emptyList() } + /** + * Получает индекс emoji согласно Unicode порядку в рамках категории + */ + private fun getEmojiSortIndex(emoji: String, category: EmojiCategory): Int { + val unified = emoji.lowercase().split("-").firstOrNull() ?: return Int.MAX_VALUE + val codePoint = try { unified.toInt(16) } catch (e: Exception) { return Int.MAX_VALUE } + + // Находим индекс диапазона, в который попадает emoji + for ((rangeIndex, range) in category.ranges.withIndex()) { + val (start, end) = range + if (codePoint in start..end) { + // Возвращаем составной индекс: номер диапазона * 100000 + позиция внутри диапазона + return rangeIndex * 100000 + (codePoint - start) + } + } + return Int.MAX_VALUE + } + private fun groupEmojis(allEmojis: List): Map> { val result = mutableMapOf>() val usedEmojis = mutableSetOf() + val emojiToCategory = mutableMapOf() EMOJI_CATEGORIES.forEach { category -> result[category.key] = mutableListOf() } + // Сначала определяем категорию для каждого emoji for (emoji in allEmojis) { for (category in EMOJI_CATEGORIES) { - if (category.key == "All") continue if (emojiMatchesCategory(emoji, category) && emoji !in usedEmojis) { result[category.key]?.add(emoji) usedEmojis.add(emoji) + emojiToCategory[emoji] = category break } } } + // Нераспределенные emoji идут в Symbols for (emoji in allEmojis) { if (emoji !in usedEmojis) { result["Symbols"]?.add(emoji) + emojiToCategory[emoji] = EMOJI_CATEGORIES.find { it.key == "Symbols" }!! + } + } + + // Сортируем каждую категорию согласно Unicode порядку + for ((key, emojis) in result) { + val category = EMOJI_CATEGORIES.find { it.key == key } + if (category != null) { + emojis.sortWith { a, b -> + getEmojiSortIndex(a, category).compareTo(getEmojiSortIndex(b, category)) + } } }