feat: Enhance performance and usability in chat components and emoji handling

This commit is contained in:
k1ngsterr1
2026-02-08 08:18:49 +05:00
parent 11a8ff7644
commit 8b8c883a63
7 changed files with 206 additions and 95 deletions

2
.gitignore vendored
View File

@@ -84,3 +84,5 @@ lint/tmp/
# Mac # Mac
.DS_Store .DS_Store
CHANGELOG.md

View File

@@ -18,6 +18,7 @@ import java.security.spec.PKCS8EncodedKeySpec
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.util.zip.Deflater import java.util.zip.Deflater
import java.util.zip.Inflater import java.util.zip.Inflater
import java.util.concurrent.ConcurrentHashMap
import com.google.crypto.tink.subtle.XChaCha20Poly1305 import com.google.crypto.tink.subtle.XChaCha20Poly1305
/** /**
@@ -39,15 +40,10 @@ object CryptoManager {
// Кэшируем производный ключ для каждого пароля чтобы не вычислять его каждый раз // Кэшируем производный ключ для каждого пароля чтобы не вычислять его каждый раз
private val pbkdf2KeyCache = mutableMapOf<String, SecretKeySpec>() private val pbkdf2KeyCache = mutableMapOf<String, SecretKeySpec>()
// 🚀 ОПТИМИЗАЦИЯ: LRU-кэш для расшифрованных сообщений // 🚀 ОПТИМИЗАЦИЯ: Lock-free кэш для расшифрованных сообщений
// Ключ = encryptedData, Значение = расшифрованный текст // ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной расшифровке
// Ограничиваем размер чтобы не съесть память private const val DECRYPTION_CACHE_SIZE = 2000
private const val DECRYPTION_CACHE_SIZE = 500 private val decryptionCache = ConcurrentHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, 4)
private val decryptionCache = object : LinkedHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?): Boolean {
return size > DECRYPTION_CACHE_SIZE
}
}
init { init {
// Add BouncyCastle provider for secp256k1 support // Add BouncyCastle provider for secp256k1 support
@@ -73,9 +69,7 @@ object CryptoManager {
*/ */
fun clearCaches() { fun clearCaches() {
pbkdf2KeyCache.clear() pbkdf2KeyCache.clear()
synchronized(decryptionCache) { decryptionCache.clear()
decryptionCache.clear()
}
keyPairCache.clear() keyPairCache.clear()
privateKeyHashCache.clear() privateKeyHashCache.clear()
} }
@@ -306,20 +300,22 @@ object CryptoManager {
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений * 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
*/ */
fun decryptWithPassword(encryptedData: String, password: String): String? { fun decryptWithPassword(encryptedData: String, password: String): String? {
// 🚀 ОПТИМИЗАЦИЯ: Проверяем кэш расшифрованных сообщений // 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
val cacheKey = "$password:$encryptedData" val cacheKey = "$password:$encryptedData"
synchronized(decryptionCache) { decryptionCache[cacheKey]?.let { return it }
decryptionCache[cacheKey]?.let { return it }
}
return try { return try {
val result = decryptWithPasswordInternal(encryptedData, password) val result = decryptWithPasswordInternal(encryptedData, password)
// 🚀 Сохраняем в кэш // 🚀 Сохраняем в кэш (lock-free)
if (result != null) { if (result != null) {
synchronized(decryptionCache) { // Ограничиваем размер кэша
decryptionCache[cacheKey] = result if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
// Удаляем ~10% самых старых записей
val keysToRemove = decryptionCache.keys.take(DECRYPTION_CACHE_SIZE / 10)
keysToRemove.forEach { decryptionCache.remove(it) }
} }
decryptionCache[cacheKey] = result
} }
result result
@@ -403,6 +399,11 @@ object CryptoManager {
* Check if data is in old format (base64-encoded hex with ":") * Check if data is in old format (base64-encoded hex with ":")
*/ */
private fun isOldFormat(data: String): Boolean { private fun isOldFormat(data: String): Boolean {
// 🚀 Fast path: new format always contains ':' at plaintext level (iv:ct)
// Old format is a single base64 blob without ':' in the encoded string
if (data.contains(':')) return false
if (data.startsWith("CHNK:")) return false
return try { return try {
val decoded = String(Base64.decode(data, Base64.NO_WRAP), Charsets.UTF_8) val decoded = String(Base64.decode(data, Base64.NO_WRAP), Charsets.UTF_8)
decoded.contains(":") && decoded.split(":").all { part -> decoded.contains(":") && decoded.split(":").all { part ->

View File

@@ -224,6 +224,9 @@ fun ChatDetailScreen(
// 🖼 Состояние для multi-image editor (галерея) // 🖼 Состояние для multi-image editor (галерея)
var pendingGalleryImages by remember { mutableStateOf<List<Uri>>(emptyList()) } var pendingGalleryImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
// Триггер для возврата фокуса на инпут после отправки фото из редактора
var inputFocusTrigger by remember { mutableStateOf(0) }
// <20>📷 Camera launcher // <20>📷 Camera launcher
val cameraLauncher = val cameraLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
@@ -1501,7 +1504,9 @@ fun ChatDetailScreen(
opponentPublicKey = opponentPublicKey =
user.publicKey, user.publicKey,
myPrivateKey = myPrivateKey =
currentUserPrivateKey currentUserPrivateKey,
inputFocusTrigger =
inputFocusTrigger
) )
} }
} }
@@ -1943,6 +1948,7 @@ fun ChatDetailScreen(
onMediaSelectedWithCaption = { mediaItem, caption -> onMediaSelectedWithCaption = { mediaItem, caption ->
// 📸 Отправляем фото с caption напрямую // 📸 Отправляем фото с caption напрямую
showMediaPicker = false showMediaPicker = false
inputFocusTrigger++
scope.launch { scope.launch {
val base64 = val base64 =
MediaUtils.uriToBase64Image( MediaUtils.uriToBase64Image(
@@ -2253,14 +2259,19 @@ fun ChatDetailScreen(
pendingCameraPhotoUri?.let { uri -> pendingCameraPhotoUri?.let { uri ->
ImageEditorScreen( ImageEditorScreen(
imageUri = uri, imageUri = uri,
onDismiss = { pendingCameraPhotoUri = null }, onDismiss = {
pendingCameraPhotoUri = null
inputFocusTrigger++
},
onSave = { editedUri -> onSave = { editedUri ->
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ // 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
viewModel.sendImageFromUri(editedUri, "") viewModel.sendImageFromUri(editedUri, "")
showMediaPicker = false
}, },
onSaveWithCaption = { editedUri, caption -> onSaveWithCaption = { editedUri, caption ->
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ // 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
viewModel.sendImageFromUri(editedUri, caption) viewModel.sendImageFromUri(editedUri, caption)
showMediaPicker = false
}, },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
showCaptionInput = true, showCaptionInput = true,
@@ -2273,7 +2284,10 @@ fun ChatDetailScreen(
if (pendingGalleryImages.isNotEmpty()) { if (pendingGalleryImages.isNotEmpty()) {
MultiImageEditorScreen( MultiImageEditorScreen(
imageUris = pendingGalleryImages, imageUris = pendingGalleryImages,
onDismiss = { pendingGalleryImages = emptyList() }, onDismiss = {
pendingGalleryImages = emptyList()
inputFocusTrigger++
},
onSendAll = { imagesWithCaptions -> onSendAll = { imagesWithCaptions ->
// 🚀 Мгновенный optimistic UI для каждого фото // 🚀 Мгновенный optimistic UI для каждого фото
for (imageWithCaption in imagesWithCaptions) { for (imageWithCaption in imagesWithCaptions) {
@@ -2282,6 +2296,7 @@ fun ChatDetailScreen(
imageWithCaption.caption imageWithCaption.caption
) )
} }
showMediaPicker = false
}, },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
recipientName = user.title recipientName = user.title

View File

@@ -20,6 +20,8 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@@ -36,7 +38,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
companion object { companion object {
private const val TAG = "ChatViewModel" private const val TAG = "ChatViewModel"
private const val PAGE_SIZE = 30 private const val PAGE_SIZE = 30
private const val DECRYPT_CHUNK_SIZE = 5 // Расшифровываем по 5 сообщений за раз private const val DECRYPT_CHUNK_SIZE = 15 // Расшифровываем по 15 сообщений за раз
private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>) // 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
@@ -499,8 +502,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val hasForward = forwardMessages.isNotEmpty() val hasForward = forwardMessages.isNotEmpty()
if (hasForward) {} if (hasForward) {}
// 🚀 P1: Проверяем кэш ДО сброса состояния — если есть, подставляем мгновенно (без flash)
val account = myPublicKey
val cachedMessages =
if (account != null) {
val dialogKey = getDialogKey(account, publicKey)
dialogMessagesCache[dialogKey]
} else null
// Сбрасываем состояние // Сбрасываем состояние
_messages.value = emptyList() if (cachedMessages != null && cachedMessages.isNotEmpty()) {
// Мгновенная подстановка из кэша — пользователь не увидит пустой экран
_messages.value = cachedMessages
_isLoading.value = false
} else {
// Нет кэша — показываем skeleton вместо пустого Lottie
_isLoading.value = true
_messages.value = emptyList()
}
_opponentOnline.value = false _opponentOnline.value = false
_opponentTyping.value = false _opponentTyping.value = false
typingTimeoutJob?.cancel() typingTimeoutJob?.cancel()
@@ -537,9 +556,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Подписываемся на онлайн статус // Подписываемся на онлайн статус
subscribeToOnlineStatus() subscribeToOnlineStatus()
// 🔥 ОПТИМИЗАЦИЯ: Загружаем сообщения ПОСЛЕ задержки для плавной анимации // <EFBFBD> P1.2: Загружаем сообщения СРАЗУ — параллельно с анимацией SwipeBackContainer
// 250ms - это время анимации перехода в чат loadMessagesFromDatabase(delayMs = 0L)
loadMessagesFromDatabase(delayMs = 250L)
} }
/** /**
@@ -651,19 +669,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
hasMoreMessages = entities.size >= PAGE_SIZE hasMoreMessages = entities.size >= PAGE_SIZE
currentOffset = entities.size currentOffset = entities.size
// 🔥 ЧАНКОВАЯ расшифровка - по DECRYPT_CHUNK_SIZE сообщений с yield между // <EFBFBD> P2.2: ПАРАЛЛЕЛЬНАЯ расшифровка с Semaphore
// ними // Запускаем до DECRYPT_PARALLELISM одновременных расшифровок
// Это предотвращает блокировку UI thread
val messages = ArrayList<ChatMessage>(entities.size)
val reversedEntities = entities.asReversed() val reversedEntities = entities.asReversed()
for ((index, entity) in reversedEntities.withIndex()) { val semaphore = Semaphore(DECRYPT_PARALLELISM)
val chatMsg = entityToChatMessage(entity) val messages = coroutineScope {
messages.add(chatMsg) reversedEntities
.map { entity ->
// Каждые DECRYPT_CHUNK_SIZE сообщений даём UI thread "подышать" async {
if ((index + 1) % DECRYPT_CHUNK_SIZE == 0) { semaphore.withPermit { entityToChatMessage(entity) }
yield() // Позволяем другим корутинам выполниться }
} }
.awaitAll()
} }
// 🔥 Сохраняем в кэш для мгновенной повторной загрузки! // 🔥 Сохраняем в кэш для мгновенной повторной загрузки!
@@ -752,9 +769,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val newEntities = entities.filter { it.messageId !in existingIds } val newEntities = entities.filter { it.messageId !in existingIds }
if (newEntities.isNotEmpty()) { if (newEntities.isNotEmpty()) {
val newMessages = ArrayList<ChatMessage>(newEntities.size) val semaphore = Semaphore(DECRYPT_PARALLELISM)
for (entity in newEntities) { val newMessages = coroutineScope {
newMessages.add(entityToChatMessage(entity)) newEntities
.map { entity ->
async { semaphore.withPermit { entityToChatMessage(entity) } }
}
.awaitAll()
} }
// 🔥 ДОБАВЛЯЕМ новые к текущим, а не заменяем! // 🔥 ДОБАВЛЯЕМ новые к текущим, а не заменяем!
@@ -840,8 +861,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
currentOffset += entities.size currentOffset += entities.size
if (entities.isNotEmpty()) { if (entities.isNotEmpty()) {
val semaphore = Semaphore(DECRYPT_PARALLELISM)
val newMessages = val newMessages =
entities.map { entity -> entityToChatMessage(entity) }.asReversed() coroutineScope {
entities
.map { entity ->
async {
semaphore.withPermit {
entityToChatMessage(entity)
}
}
}
.awaitAll()
}
.asReversed()
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш при загрузке старых сообщений! // 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш при загрузке старых сообщений!
// Это предотвращает потерю сообщений при повторном открытии диалога // Это предотвращает потерю сообщений при повторном открытии диалога
@@ -876,58 +909,81 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
*/ */
private suspend fun entityToChatMessage(entity: MessageEntity): ChatMessage { private suspend fun entityToChatMessage(entity: MessageEntity): ChatMessage {
// 🚀 P2.1: Проверяем кэш расшифрованного текста по messageId
val cachedText = decryptionCache[entity.messageId]
// Расшифровываем сообщение из content + chachaKey // Расшифровываем сообщение из content + chachaKey
var plainKeyAndNonce: ByteArray? = null // 🚀 P2.3: Сохраняем для reply расшифровки
var displayText = var displayText =
try { if (cachedText != null) {
val privateKey = myPrivateKey cachedText
if (privateKey != null && } else
entity.content.isNotEmpty() && try {
entity.chachaKey.isNotEmpty() val privateKey = myPrivateKey
) { if (privateKey != null &&
// Расшифровываем как в архиве: content + chachaKey + privateKey entity.content.isNotEmpty() &&
val decrypted = entity.chachaKey.isNotEmpty()
MessageCrypto.decryptIncoming( ) {
ciphertext = entity.content, // Расшифровываем как в архиве: content + chachaKey + privateKey
encryptedKey = entity.chachaKey, // 🚀 Используем Full версию чтобы получить plainKeyAndNonce для
myPrivateKey = privateKey // reply
) val decryptResult =
decrypted MessageCrypto.decryptIncomingFull(
} else { ciphertext = entity.content,
// Fallback на расшифровку plainMessage с приватным ключом encryptedKey = entity.chachaKey,
if (privateKey != null && entity.plainMessage.isNotEmpty()) { myPrivateKey = privateKey
try { )
CryptoManager.decryptWithPassword(entity.plainMessage, privateKey) plainKeyAndNonce = decryptResult.plainKeyAndNonce
?: entity.plainMessage val decrypted = decryptResult.plaintext
} catch (e: Exception) { // 🚀 Сохраняем в кэш
decryptionCache[entity.messageId] = decrypted
decrypted
} else {
// Fallback на расшифровку plainMessage с приватным ключом
if (privateKey != null && entity.plainMessage.isNotEmpty()) {
try {
val result =
CryptoManager.decryptWithPassword(
entity.plainMessage,
privateKey
)
?: entity.plainMessage
decryptionCache[entity.messageId] = result
result
} catch (e: Exception) {
entity.plainMessage
}
} else {
entity.plainMessage
}
}
} catch (e: Exception) {
// Пробуем расшифровать 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 entity.plainMessage
} }
} else {
entity.plainMessage
} }
}
} catch (e: Exception) {
// Пробуем расшифровать 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 (цитата)
// 🔥 ВАЖНО: Передаем content и chachaKey для расшифровки reply blob если нужно // <EFBFBD> P2.3: Передаём plainKeyAndNonce чтобы избежать повторного ECDH
var replyData = var replyData =
parseReplyFromAttachments( parseReplyFromAttachments(
attachmentsJson = entity.attachments, attachmentsJson = entity.attachments,
isFromMe = entity.fromMe == 1, isFromMe = entity.fromMe == 1,
content = entity.content, content = entity.content,
chachaKey = entity.chachaKey chachaKey = entity.chachaKey,
plainKeyAndNonce = plainKeyAndNonce
) )
// Если не нашли reply в attachments, пробуем распарсить из текста // Если не нашли reply в attachments, пробуем распарсить из текста
@@ -1063,7 +1119,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
attachmentsJson: String, attachmentsJson: String,
isFromMe: Boolean, isFromMe: Boolean,
content: String, content: String,
chachaKey: String chachaKey: String,
plainKeyAndNonce: ByteArray? =
null // 🚀 P2.3: Переиспользуем ключ из основной расшифровки
): ReplyData? { ): ReplyData? {
if (attachmentsJson.isEmpty() || attachmentsJson == "[]") { if (attachmentsJson.isEmpty() || attachmentsJson == "[]") {
@@ -1133,8 +1191,23 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} catch (e: Exception) {} } catch (e: Exception) {}
} }
// 🔥 Способ 3: Пробуем decryptReplyBlob с plainKeyAndNonce // <EFBFBD> Способ 3: Используем plainKeyAndNonce из основной расшифровки (без
// повторного ECDH)
if (!decryptionSuccess && plainKeyAndNonce != null) {
try {
val decrypted =
MessageCrypto.decryptReplyBlob(dataJson, plainKeyAndNonce)
if (decrypted.isNotEmpty()) {
dataJson = decrypted
decryptionSuccess = true
}
} catch (e: Exception) {}
}
// 🔥 Способ 4 (fallback): Полный decryptIncomingFull если нет
// plainKeyAndNonce
if (!decryptionSuccess && if (!decryptionSuccess &&
plainKeyAndNonce == null &&
content.isNotEmpty() && content.isNotEmpty() &&
chachaKey.isNotEmpty() && chachaKey.isNotEmpty() &&
privateKey != null privateKey != null
@@ -1146,9 +1219,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
chachaKey, chachaKey,
privateKey privateKey
) )
val plainKeyAndNonce = decryptResult.plainKeyAndNonce val keyAndNonce = decryptResult.plainKeyAndNonce
val decrypted = val decrypted =
MessageCrypto.decryptReplyBlob(dataJson, plainKeyAndNonce) MessageCrypto.decryptReplyBlob(dataJson, keyAndNonce)
if (decrypted.isNotEmpty()) { if (decrypted.isNotEmpty()) {
dataJson = decrypted dataJson = decrypted
decryptionSuccess = true decryptionSuccess = true

View File

@@ -85,7 +85,8 @@ fun MessageInputBar(
onAttachClick: () -> Unit = {}, onAttachClick: () -> Unit = {},
myPublicKey: String = "", myPublicKey: String = "",
opponentPublicKey: String = "", opponentPublicKey: String = "",
myPrivateKey: String = "" myPrivateKey: String = "",
inputFocusTrigger: Int = 0
) { ) {
val hasReply = replyMessages.isNotEmpty() val hasReply = replyMessages.isNotEmpty()
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@@ -111,6 +112,18 @@ fun MessageInputBar(
} }
} }
// Return focus to input after closing the photo editor
LaunchedEffect(inputFocusTrigger) {
if (inputFocusTrigger > 0) {
delay(100)
editTextView?.let { editText ->
editText.requestFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
}
}
}
val imeInsets = WindowInsets.ime val imeInsets = WindowInsets.ime
var isKeyboardVisible by remember { mutableStateOf(false) } var isKeyboardVisible by remember { mutableStateOf(false) }
var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) } var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) }

View File

@@ -126,12 +126,15 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
fun setTextWithEmojis(newText: String) { fun setTextWithEmojis(newText: String) {
if (newText == text.toString()) return if (newText == text.toString()) return
isUpdating = true isUpdating = true
val cursorPos = selectionStart try {
setText(newText) val cursorPos = selectionStart
// Восстанавливаем позицию курсора в конец текста setText(newText)
val newCursorPos = if (cursorPos >= 0) newText.length else cursorPos // Восстанавливаем позицию курсора в конец текста
setSelection(newCursorPos.coerceIn(0, newText.length)) val newCursorPos = if (cursorPos >= 0) newText.length else cursorPos
isUpdating = false setSelection(newCursorPos.coerceIn(0, newText.length))
} finally {
isUpdating = false
}
replaceEmojisWithImages(editableText) replaceEmojisWithImages(editableText)
} }
@@ -297,6 +300,10 @@ fun AppleEmojiTextField(
} }
}, },
update = { view -> update = { view ->
// Always update the callback to prevent stale lambda references
// after recomposition (e.g., after sending a photo)
view.onTextChange = onValueChange
if (view.text.toString() != value) { if (view.text.toString() != value) {
view.setTextWithEmojis(value) view.setTextWithEmojis(value)
} }

View File

@@ -32,7 +32,7 @@ object OptimizedEmojiCache {
private set private set
private const val PRELOAD_COUNT = 200 // Предзагружаем первые 200 эмодзи private const val PRELOAD_COUNT = 200 // Предзагружаем первые 200 эмодзи
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
/** /**
* Предзагрузка при старте приложения * Предзагрузка при старте приложения