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:
@@ -157,6 +157,9 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
// Сериализуем attachments в JSON
|
// Сериализуем attachments в JSON
|
||||||
val attachmentsJson = serializeAttachments(attachments)
|
val attachmentsJson = serializeAttachments(attachments)
|
||||||
|
|
||||||
|
// 🔒 Шифруем plainMessage с использованием приватного ключа
|
||||||
|
val encryptedPlainMessage = CryptoManager.encryptWithPassword(text.trim(), privateKey)
|
||||||
|
|
||||||
// Сохраняем в БД
|
// Сохраняем в БД
|
||||||
val entity = MessageEntity(
|
val entity = MessageEntity(
|
||||||
account = account,
|
account = account,
|
||||||
@@ -169,7 +172,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
fromMe = 1,
|
fromMe = 1,
|
||||||
delivered = DeliveryStatus.WAITING.value,
|
delivered = DeliveryStatus.WAITING.value,
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
plainMessage = text.trim(),
|
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
||||||
attachments = attachmentsJson,
|
attachments = attachmentsJson,
|
||||||
replyToMessageId = replyToMessageId,
|
replyToMessageId = replyToMessageId,
|
||||||
dialogKey = dialogKey
|
dialogKey = dialogKey
|
||||||
@@ -230,6 +233,9 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
privateKey
|
privateKey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 🔒 Шифруем plainMessage с использованием приватного ключа
|
||||||
|
val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey)
|
||||||
|
|
||||||
// Сохраняем в БД
|
// Сохраняем в БД
|
||||||
val entity = MessageEntity(
|
val entity = MessageEntity(
|
||||||
account = account,
|
account = account,
|
||||||
@@ -242,7 +248,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
fromMe = 0,
|
fromMe = 0,
|
||||||
delivered = DeliveryStatus.DELIVERED.value,
|
delivered = DeliveryStatus.DELIVERED.value,
|
||||||
messageId = packet.messageId,
|
messageId = packet.messageId,
|
||||||
plainMessage = plainText,
|
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
||||||
attachments = attachmentsJson,
|
attachments = attachmentsJson,
|
||||||
dialogKey = dialogKey
|
dialogKey = dialogKey
|
||||||
)
|
)
|
||||||
@@ -405,18 +411,33 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extension functions
|
// Extension functions
|
||||||
private fun MessageEntity.toMessage() = Message(
|
private fun MessageEntity.toMessage(): Message {
|
||||||
id = id,
|
// 🔓 Расшифровываем plainMessage с использованием приватного ключа
|
||||||
messageId = messageId,
|
val privateKey = currentPrivateKey
|
||||||
fromPublicKey = fromPublicKey,
|
val decryptedText = if (privateKey != null && plainMessage.isNotEmpty()) {
|
||||||
toPublicKey = toPublicKey,
|
try {
|
||||||
content = plainMessage,
|
CryptoManager.decryptWithPassword(plainMessage, privateKey) ?: plainMessage
|
||||||
timestamp = timestamp,
|
} catch (e: Exception) {
|
||||||
isFromMe = fromMe == 1,
|
android.util.Log.e("MessageRepository", "Failed to decrypt plainMessage: ${e.message}")
|
||||||
isRead = read == 1,
|
plainMessage // Fallback на зашифрованный текст если расшифровка не удалась
|
||||||
deliveryStatus = DeliveryStatus.fromInt(delivered),
|
}
|
||||||
replyToMessageId = replyToMessageId
|
} 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(
|
private fun DialogEntity.toDialog() = Dialog(
|
||||||
opponentKey = opponentKey,
|
opponentKey = opponentKey,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ data class MessageEntity(
|
|||||||
val messageId: String, // UUID сообщения
|
val messageId: String, // UUID сообщения
|
||||||
|
|
||||||
@ColumnInfo(name = "plain_message")
|
@ColumnInfo(name = "plain_message")
|
||||||
val plainMessage: String, // Расшифрованный текст (для быстрого доступа)
|
val plainMessage: String, // 🔒 Зашифрованный текст (encryptWithPassword) для хранения в БД
|
||||||
|
|
||||||
@ColumnInfo(name = "attachments")
|
@ColumnInfo(name = "attachments")
|
||||||
val attachments: String = "[]", // JSON массив вложений
|
val attachments: String = "[]", // JSON массив вложений
|
||||||
|
|||||||
@@ -155,7 +155,21 @@ enum class MessageStatus {
|
|||||||
SENDING,
|
SENDING,
|
||||||
SENT,
|
SENT,
|
||||||
DELIVERED,
|
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 или полная дата) */
|
/** Получить текст даты (today, yesterday или полная дата) */
|
||||||
@@ -895,6 +909,14 @@ fun ChatDetailScreen(
|
|||||||
onSwipeToReply = {
|
onSwipeToReply = {
|
||||||
// 🔥 Swipe-to-reply: добавляем это сообщение в reply
|
// 🔥 Swipe-to-reply: добавляем это сообщение в reply
|
||||||
viewModel.setReplyMessages(listOf(message))
|
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,
|
isSelected: Boolean = false,
|
||||||
onLongClick: () -> Unit = {},
|
onLongClick: () -> Unit = {},
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
onSwipeToReply: () -> Unit = {}
|
onSwipeToReply: () -> Unit = {},
|
||||||
|
onRetry: () -> Unit = {}, // 🔥 Retry для ошибки
|
||||||
|
onDelete: () -> Unit = {} // 🔥 Delete для ошибки
|
||||||
) {
|
) {
|
||||||
// 🔥 Swipe-to-reply state (как в Telegram)
|
// 🔥 Swipe-to-reply state (как в Telegram)
|
||||||
var swipeOffset by remember { mutableStateOf(0f) }
|
var swipeOffset by remember { mutableStateOf(0f) }
|
||||||
@@ -1587,7 +1611,10 @@ private fun MessageBubble(
|
|||||||
Spacer(modifier = Modifier.width(3.dp))
|
Spacer(modifier = Modifier.width(3.dp))
|
||||||
AnimatedMessageStatus(
|
AnimatedMessageStatus(
|
||||||
status = message.status,
|
status = message.status,
|
||||||
timeColor = timeColor
|
timeColor = timeColor,
|
||||||
|
timestamp = message.timestamp.time,
|
||||||
|
onRetry = onRetry,
|
||||||
|
onDelete = onDelete
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1599,14 +1626,26 @@ private fun MessageBubble(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 🎯 Анимированный статус сообщения с плавными переходами
|
* 🎯 Анимированный статус сообщения с плавными переходами
|
||||||
|
* Поддерживает ERROR статус с красной иконкой (как в архиве)
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AnimatedMessageStatus(
|
private fun AnimatedMessageStatus(
|
||||||
status: MessageStatus,
|
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(
|
val animatedColor by animateColorAsState(
|
||||||
targetValue = targetColor,
|
targetValue = targetColor,
|
||||||
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
|
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
|
||||||
@@ -1614,13 +1653,13 @@ private fun AnimatedMessageStatus(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Анимация scale для эффекта "pop"
|
// Анимация scale для эффекта "pop"
|
||||||
var previousStatus by remember { mutableStateOf(status) }
|
var previousStatus by remember { mutableStateOf(effectiveStatus) }
|
||||||
var shouldAnimate by remember { mutableStateOf(false) }
|
var shouldAnimate by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(status) {
|
LaunchedEffect(effectiveStatus) {
|
||||||
if (previousStatus != status) {
|
if (previousStatus != effectiveStatus) {
|
||||||
shouldAnimate = true
|
shouldAnimate = true
|
||||||
previousStatus = status
|
previousStatus = effectiveStatus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1634,25 +1673,63 @@ private fun AnimatedMessageStatus(
|
|||||||
label = "statusScale"
|
label = "statusScale"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Crossfade для плавной смены иконки
|
// 🔥 Для ошибки - показываем DropdownMenu
|
||||||
Crossfade(
|
var showErrorMenu by remember { mutableStateOf(false) }
|
||||||
targetState = status,
|
|
||||||
animationSpec = tween(durationMillis = 200),
|
Box {
|
||||||
label = "statusIcon"
|
// Crossfade для плавной смены иконки
|
||||||
) { currentStatus ->
|
Crossfade(
|
||||||
Icon(
|
targetState = effectiveStatus,
|
||||||
imageVector = when (currentStatus) {
|
animationSpec = tween(durationMillis = 200),
|
||||||
MessageStatus.SENDING -> Icons.Default.Schedule
|
label = "statusIcon"
|
||||||
MessageStatus.SENT -> Icons.Default.Done
|
) { currentStatus ->
|
||||||
MessageStatus.DELIVERED -> Icons.Default.DoneAll
|
Icon(
|
||||||
MessageStatus.READ -> Icons.Default.DoneAll
|
imageVector = when (currentStatus) {
|
||||||
},
|
MessageStatus.SENDING -> Icons.Default.Schedule // Часики - отправляется
|
||||||
contentDescription = null,
|
MessageStatus.SENT -> Icons.Default.Done // Одна галочка - отправлено
|
||||||
tint = animatedColor,
|
MessageStatus.DELIVERED -> Icons.Default.Done // Одна галочка - доставлено
|
||||||
modifier = Modifier
|
MessageStatus.READ -> Icons.Default.DoneAll // Две галочки - прочитано
|
||||||
.size(16.dp)
|
MessageStatus.ERROR -> Icons.Default.Error // Ошибка - восклицательный знак
|
||||||
.scale(scale)
|
},
|
||||||
)
|
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)
|
// 🔥 Сохраняем в БД здесь (в ChatViewModel)
|
||||||
// ProtocolManager.setupPacketHandlers() не вызывается, поэтому сохраняем сами
|
// ProtocolManager.setupPacketHandlers() не вызывается, поэтому сохраняем сами
|
||||||
|
// Используем fromPublicKey как opponent для корректного dialogKey
|
||||||
|
val senderKey = packet.fromPublicKey
|
||||||
saveMessageToDatabase(
|
saveMessageToDatabase(
|
||||||
messageId = packet.messageId,
|
messageId = packet.messageId,
|
||||||
text = decryptedText,
|
text = decryptedText,
|
||||||
@@ -306,11 +308,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
timestamp = packet.timestamp,
|
timestamp = packet.timestamp,
|
||||||
isFromMe = false, // Это входящее сообщение
|
isFromMe = false, // Это входящее сообщение
|
||||||
delivered = DeliveryStatus.DELIVERED.value,
|
delivered = DeliveryStatus.DELIVERED.value,
|
||||||
attachmentsJson = attachmentsJson
|
attachmentsJson = attachmentsJson,
|
||||||
|
opponentPublicKey = senderKey
|
||||||
)
|
)
|
||||||
|
|
||||||
// 🔥 Обновляем диалог
|
// 🔥 Обновляем диалог - используем senderKey
|
||||||
updateDialog(opponentKey!!, decryptedText, packet.timestamp, incrementUnread = !isDialogActive)
|
updateDialog(senderKey, decryptedText, packet.timestamp, incrementUnread = !isDialogActive)
|
||||||
|
|
||||||
// 👁️ НЕ отправляем read receipt автоматически!
|
// 👁️ НЕ отправляем read receipt автоматически!
|
||||||
// Read receipt отправляется только когда пользователь видит сообщение
|
// Read receipt отправляется только когда пользователь видит сообщение
|
||||||
@@ -428,12 +431,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
hasMoreMessages = entities.size >= PAGE_SIZE
|
hasMoreMessages = entities.size >= PAGE_SIZE
|
||||||
currentOffset = entities.size
|
currentOffset = entities.size
|
||||||
|
|
||||||
// 🔥 ОПТИМИЗАЦИЯ: Быстрая конвертация в одном проходе
|
// 🔥 Расшифровка сообщений при загрузке (как в архиве)
|
||||||
val messages = ArrayList<ChatMessage>(entities.size)
|
val messages = ArrayList<ChatMessage>(entities.size)
|
||||||
for (entity in entities.asReversed()) {
|
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 - пользователь видит сообщения мгновенно
|
// 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно
|
||||||
withContext(Dispatchers.Main.immediate) {
|
withContext(Dispatchers.Main.immediate) {
|
||||||
_messages.value = messages
|
_messages.value = messages
|
||||||
@@ -495,7 +501,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
if (entities.isNotEmpty()) {
|
if (entities.isNotEmpty()) {
|
||||||
val newMessages = entities.map { entity ->
|
val newMessages = entities.map { entity ->
|
||||||
entityToChatMessage(entity)
|
entityToChatMessage(entity)
|
||||||
}.reversed()
|
}.asReversed()
|
||||||
|
|
||||||
// Добавляем в начало списка (старые сообщения)
|
// Добавляем в начало списка (старые сообщения)
|
||||||
withContext(Dispatchers.Main) {
|
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 (цитата)
|
// Парсим attachments для поиска MESSAGES (цитата)
|
||||||
var replyData = parseReplyFromAttachments(entity.attachments, entity.fromMe == 1)
|
var replyData = parseReplyFromAttachments(entity.attachments, entity.fromMe == 1)
|
||||||
|
|
||||||
// Текст сообщения и возможный fallback reply из текста
|
|
||||||
var displayText = entity.plainMessage
|
|
||||||
|
|
||||||
// Если не нашли reply в attachments, пробуем распарсить из текста
|
// Если не нашли reply в attachments, пробуем распарсить из текста
|
||||||
// Формат: "🇵 Reply: "текст цитаты"\n\nоснователь текст" или подобный
|
|
||||||
if (replyData == null) {
|
if (replyData == null) {
|
||||||
val parseResult = parseReplyFromText(entity.plainMessage)
|
val parseResult = parseReplyFromText(displayText)
|
||||||
if (parseResult != null) {
|
if (parseResult != null) {
|
||||||
replyData = parseResult.first
|
replyData = parseResult.first
|
||||||
displayText = parseResult.second
|
displayText = parseResult.second
|
||||||
@@ -540,13 +583,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
return ChatMessage(
|
return ChatMessage(
|
||||||
id = entity.messageId,
|
id = entity.messageId,
|
||||||
text = displayText, // Уже расшифровано при сохранении
|
text = displayText,
|
||||||
isOutgoing = entity.fromMe == 1,
|
isOutgoing = entity.fromMe == 1,
|
||||||
timestamp = Date(entity.timestamp),
|
timestamp = Date(entity.timestamp),
|
||||||
status = when (entity.delivered) {
|
status = when (entity.delivered) {
|
||||||
0 -> MessageStatus.SENDING
|
0 -> MessageStatus.SENDING
|
||||||
1 -> MessageStatus.DELIVERED
|
1 -> MessageStatus.DELIVERED
|
||||||
2 -> MessageStatus.SENT // Changed from ERROR to SENT
|
2 -> MessageStatus.SENT
|
||||||
3 -> MessageStatus.READ
|
3 -> MessageStatus.READ
|
||||||
else -> MessageStatus.SENT
|
else -> MessageStatus.SENT
|
||||||
},
|
},
|
||||||
@@ -693,6 +736,41 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
_replyMessages.value = emptyList()
|
_replyMessages.value = emptyList()
|
||||||
_isForwardMode.value = false
|
_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,
|
encryptedKey = encryptedKey,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isFromMe = true,
|
isFromMe = true,
|
||||||
delivered = 1,
|
delivered = 0, // 🔥 SENDING - ждём PacketDelivery для DELIVERED
|
||||||
attachmentsJson = attachmentsJson
|
attachmentsJson = attachmentsJson
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -971,6 +1049,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить сообщение в базу данных
|
* Сохранить сообщение в базу данных
|
||||||
|
* 🔒 Безопасность: plainMessage НЕ сохраняется - только зашифрованный content + chachaKey
|
||||||
|
* @param text - расшифрованный текст (используется только для логов и обновления диалога)
|
||||||
|
* @param opponentPublicKey - публичный ключ собеседника (используется вместо глобального opponentKey)
|
||||||
*/
|
*/
|
||||||
private suspend fun saveMessageToDatabase(
|
private suspend fun saveMessageToDatabase(
|
||||||
messageId: String,
|
messageId: String,
|
||||||
@@ -980,14 +1061,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
timestamp: Long,
|
timestamp: Long,
|
||||||
isFromMe: Boolean,
|
isFromMe: Boolean,
|
||||||
delivered: Int = 0,
|
delivered: Int = 0,
|
||||||
attachmentsJson: String = "[]"
|
attachmentsJson: String = "[]",
|
||||||
|
opponentPublicKey: String? = null
|
||||||
) {
|
) {
|
||||||
val account = myPublicKey ?: return
|
val account = myPublicKey ?: return
|
||||||
val opponent = opponentKey ?: return
|
val opponent = opponentPublicKey ?: opponentKey ?: return
|
||||||
|
val privateKey = this.privateKey ?: return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val dialogKey = getDialogKey(account, opponent)
|
val dialogKey = getDialogKey(account, opponent)
|
||||||
|
|
||||||
|
// 🔒 Шифруем plainMessage с использованием приватного ключа
|
||||||
|
val encryptedPlainMessage = CryptoManager.encryptWithPassword(text, privateKey)
|
||||||
|
|
||||||
// Проверяем существует ли сообщение
|
// Проверяем существует ли сообщение
|
||||||
val exists = messageDao.messageExists(account, messageId)
|
val exists = messageDao.messageExists(account, messageId)
|
||||||
ProtocolManager.addLog("💾 Saving message to DB:")
|
ProtocolManager.addLog("💾 Saving message to DB:")
|
||||||
@@ -1007,7 +1093,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
fromMe = if (isFromMe) 1 else 0,
|
fromMe = if (isFromMe) 1 else 0,
|
||||||
delivered = delivered,
|
delivered = delivered,
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
plainMessage = text,
|
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст для хранения в БД
|
||||||
attachments = attachmentsJson,
|
attachments = attachmentsJson,
|
||||||
replyToMessageId = null,
|
replyToMessageId = null,
|
||||||
dialogKey = dialogKey
|
dialogKey = dialogKey
|
||||||
|
|||||||
@@ -53,81 +53,303 @@ data class EmojiCategory(
|
|||||||
val ranges: List<Pair<Int, Int>>
|
val ranges: List<Pair<Int, Int>>
|
||||||
)
|
)
|
||||||
|
|
||||||
// Порядок категорий
|
// Unicode Standard Emoji Ordering (v15.0)
|
||||||
|
// Порядок категорий согласно Unicode CLDR
|
||||||
val EMOJI_CATEGORIES = listOf(
|
val EMOJI_CATEGORIES = listOf(
|
||||||
// 😀 Smileys & Emotion
|
// 😀 Smileys & Emotion (Unicode ordering)
|
||||||
EmojiCategory("Smileys", "Смайлы", Icons.Default.SentimentSatisfied, listOf(
|
EmojiCategory("Smileys", "Смайлы", Icons.Default.SentimentSatisfied, listOf(
|
||||||
0x1F600 to 0x1F64F,
|
// Face-Smiling
|
||||||
0x1F910 to 0x1F92F,
|
0x1F600 to 0x1F60F, // 😀-😏
|
||||||
0x1F970 to 0x1F9FF,
|
// Face-Affection
|
||||||
0x263A to 0x263A,
|
0x1F617 to 0x1F61D, // 😗-😝
|
||||||
0x2639 to 0x2639
|
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(
|
EmojiCategory("People", "Люди", Icons.Default.Person, listOf(
|
||||||
0x1F466 to 0x1F4FF,
|
// Hand-Fingers-Open
|
||||||
0x1F9D0 to 0x1F9DF,
|
0x1F44B to 0x1F44D, // 👋-👍
|
||||||
0x270A to 0x270D,
|
0x1FAF0 to 0x1FAF8, // 🫰-🫸
|
||||||
0x261D to 0x261D,
|
// Hand-Fingers-Partial
|
||||||
0x1F440 to 0x1F465
|
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
|
// 🐱 Animals & Nature
|
||||||
EmojiCategory("Animals", "Животные", Icons.Default.Pets, listOf(
|
EmojiCategory("Animals", "Животные", Icons.Default.Pets, listOf(
|
||||||
0x1F400 to 0x1F43F,
|
// Animal-Mammal
|
||||||
0x1F980 to 0x1F9AE,
|
0x1F435 to 0x1F43E, // 🐵-🐾
|
||||||
0x1F330 to 0x1F335,
|
0x1F9A0 to 0x1F9AE, // 🦠-🦮
|
||||||
0x1F337 to 0x1F34F,
|
0x1F981 to 0x1F99F, // 🦁-🦟
|
||||||
0x2618 to 0x2618
|
// 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
|
// 🍎 Food & Drink
|
||||||
EmojiCategory("Food", "Еда", Icons.Default.Restaurant, listOf(
|
EmojiCategory("Food", "Еда", Icons.Default.Restaurant, listOf(
|
||||||
0x1F345 to 0x1F37F,
|
// Food-Fruit
|
||||||
0x1F950 to 0x1F96F,
|
0x1F347 to 0x1F353, // 🍇-🍓
|
||||||
0x1F9C0 to 0x1F9CB,
|
0x1FAD0 to 0x1FAD4, // 🫐-🫔
|
||||||
0x1FAD0 to 0x1FAD9,
|
0x1F95D to 0x1F95D, // 🥝
|
||||||
0x2615 to 0x2615
|
// 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
|
// ✈️ Travel & Places
|
||||||
EmojiCategory("Travel", "Места", Icons.Default.Flight, listOf(
|
EmojiCategory("Travel", "Места", Icons.Default.Flight, listOf(
|
||||||
0x1F680 to 0x1F6FF,
|
// Place-Map
|
||||||
0x1F3D4 to 0x1F3DF,
|
0x1F30D to 0x1F310, // 🌍-🌐
|
||||||
0x1F3E0 to 0x1F3F0,
|
0x1F5FA to 0x1F5FA, // 🗺
|
||||||
0x2708 to 0x2708,
|
// Place-Geographic
|
||||||
0x26F0 to 0x26FF
|
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
|
// ⚽ Activities
|
||||||
EmojiCategory("Activities", "Спорт", Icons.Default.SportsSoccer, listOf(
|
EmojiCategory("Activities", "Спорт", Icons.Default.SportsSoccer, listOf(
|
||||||
0x1F3A0 to 0x1F3CA,
|
// Event
|
||||||
0x1F3CB to 0x1F3D3,
|
0x1F380 to 0x1F393, // 🎀-🎓
|
||||||
0x1F93C to 0x1F94F,
|
0x1F9E7 to 0x1F9E7, // 🧧
|
||||||
0x26BD to 0x26BE,
|
// Award-Medal
|
||||||
0x265F to 0x2660,
|
0x1F396 to 0x1F397, // 🎖-🎗
|
||||||
0x1F9E0 to 0x1F9FF
|
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
|
// 💡 Objects
|
||||||
EmojiCategory("Objects", "Объекты", Icons.Default.Lightbulb, listOf(
|
EmojiCategory("Objects", "Объекты", Icons.Default.Lightbulb, listOf(
|
||||||
0x1F4A1 to 0x1F4FF,
|
// Clothing
|
||||||
0x1F500 to 0x1F5FF,
|
0x1F451 to 0x1F462, // 👑-👢
|
||||||
0x1F6E0 to 0x1F6EF,
|
0x1F97B to 0x1F97F, // 🥻-🥿
|
||||||
0x1FA70 to 0x1FAFF,
|
0x1FA70 to 0x1FA7C, // 🩰-🩼
|
||||||
0x2328 to 0x2328
|
// 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
|
// ❤️ Symbols
|
||||||
EmojiCategory("Symbols", "Символы", Icons.Default.Favorite, listOf(
|
EmojiCategory("Symbols", "Символы", Icons.Default.Favorite, listOf(
|
||||||
0x2764 to 0x2764,
|
// Heart
|
||||||
0x1F490 to 0x1F49F,
|
0x2764 to 0x2764, // ❤
|
||||||
0x2600 to 0x26FF,
|
0x1F493 to 0x1F49F, // 💓-💟
|
||||||
0x2700 to 0x27BF,
|
0x1F90D to 0x1F90F, // 🤍-🤏
|
||||||
0x1F170 to 0x1F1FF,
|
0x2763 to 0x2763, // ❣
|
||||||
0x00A9 to 0x00AE,
|
0x1FA75 to 0x1FA77, // 🩵-🩷
|
||||||
0x203C to 0x3299
|
// 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
|
// 🏳️ Flags
|
||||||
EmojiCategory("Flags", "Флаги", Icons.Default.Flag, listOf(
|
EmojiCategory("Flags", "Флаги", Icons.Default.Flag, listOf(
|
||||||
0x1F1E0 to 0x1F1FF,
|
// Flag
|
||||||
0x1F3F3 to 0x1F3F4,
|
0x1F3C1 to 0x1F3C1, // 🏁
|
||||||
0x1F3C1 to 0x1F3C1,
|
0x1F6A9 to 0x1F6A9, // 🚩
|
||||||
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()
|
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<String>): Map<String, List<String>> {
|
private fun groupEmojis(allEmojis: List<String>): Map<String, List<String>> {
|
||||||
val result = mutableMapOf<String, MutableList<String>>()
|
val result = mutableMapOf<String, MutableList<String>>()
|
||||||
val usedEmojis = mutableSetOf<String>()
|
val usedEmojis = mutableSetOf<String>()
|
||||||
|
val emojiToCategory = mutableMapOf<String, EmojiCategory>()
|
||||||
|
|
||||||
EMOJI_CATEGORIES.forEach { category ->
|
EMOJI_CATEGORIES.forEach { category ->
|
||||||
result[category.key] = mutableListOf()
|
result[category.key] = mutableListOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сначала определяем категорию для каждого emoji
|
||||||
for (emoji in allEmojis) {
|
for (emoji in allEmojis) {
|
||||||
for (category in EMOJI_CATEGORIES) {
|
for (category in EMOJI_CATEGORIES) {
|
||||||
if (category.key == "All") continue
|
|
||||||
if (emojiMatchesCategory(emoji, category) && emoji !in usedEmojis) {
|
if (emojiMatchesCategory(emoji, category) && emoji !in usedEmojis) {
|
||||||
result[category.key]?.add(emoji)
|
result[category.key]?.add(emoji)
|
||||||
usedEmojis.add(emoji)
|
usedEmojis.add(emoji)
|
||||||
|
emojiToCategory[emoji] = category
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Нераспределенные emoji идут в Symbols
|
||||||
for (emoji in allEmojis) {
|
for (emoji in allEmojis) {
|
||||||
if (emoji !in usedEmojis) {
|
if (emoji !in usedEmojis) {
|
||||||
result["Symbols"]?.add(emoji)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user