From 8b8c883a6346eb2ee3e8f27f4ac1b87b051f979b Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 8 Feb 2026 08:18:49 +0500 Subject: [PATCH] feat: Enhance performance and usability in chat components and emoji handling --- .gitignore | 2 + .../rosetta/messenger/crypto/CryptoManager.kt | 39 ++-- .../messenger/ui/chats/ChatDetailScreen.kt | 21 +- .../messenger/ui/chats/ChatViewModel.kt | 203 ++++++++++++------ .../ui/chats/input/ChatDetailInput.kt | 15 +- .../ui/components/AppleEmojiEditText.kt | 19 +- .../ui/components/OptimizedEmojiCache.kt | 2 +- 7 files changed, 206 insertions(+), 95 deletions(-) diff --git a/.gitignore b/.gitignore index e3081f2..8510b9c 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,5 @@ lint/tmp/ # Mac .DS_Store + +CHANGELOG.md \ No newline at end of file diff --git a/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt b/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt index 5651508..0c12afb 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt @@ -18,6 +18,7 @@ import java.security.spec.PKCS8EncodedKeySpec import java.io.ByteArrayOutputStream import java.util.zip.Deflater import java.util.zip.Inflater +import java.util.concurrent.ConcurrentHashMap import com.google.crypto.tink.subtle.XChaCha20Poly1305 /** @@ -39,15 +40,10 @@ object CryptoManager { // Кэшируем производный ключ для каждого пароля чтобы не вычислять его каждый раз private val pbkdf2KeyCache = mutableMapOf() - // 🚀 ОПТИМИЗАЦИЯ: LRU-кэш для расшифрованных сообщений - // Ключ = encryptedData, Значение = расшифрованный текст - // Ограничиваем размер чтобы не съесть память - private const val DECRYPTION_CACHE_SIZE = 500 - private val decryptionCache = object : LinkedHashMap(DECRYPTION_CACHE_SIZE, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { - return size > DECRYPTION_CACHE_SIZE - } - } + // 🚀 ОПТИМИЗАЦИЯ: Lock-free кэш для расшифрованных сообщений + // ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной расшифровке + private const val DECRYPTION_CACHE_SIZE = 2000 + private val decryptionCache = ConcurrentHashMap(DECRYPTION_CACHE_SIZE, 0.75f, 4) init { // Add BouncyCastle provider for secp256k1 support @@ -73,9 +69,7 @@ object CryptoManager { */ fun clearCaches() { pbkdf2KeyCache.clear() - synchronized(decryptionCache) { - decryptionCache.clear() - } + decryptionCache.clear() keyPairCache.clear() privateKeyHashCache.clear() } @@ -306,20 +300,22 @@ object CryptoManager { * 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений */ fun decryptWithPassword(encryptedData: String, password: String): String? { - // 🚀 ОПТИМИЗАЦИЯ: Проверяем кэш расшифрованных сообщений + // 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap) val cacheKey = "$password:$encryptedData" - synchronized(decryptionCache) { - decryptionCache[cacheKey]?.let { return it } - } + decryptionCache[cacheKey]?.let { return it } return try { val result = decryptWithPasswordInternal(encryptedData, password) - // 🚀 Сохраняем в кэш + // 🚀 Сохраняем в кэш (lock-free) 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 @@ -403,6 +399,11 @@ object CryptoManager { * Check if data is in old format (base64-encoded hex with ":") */ 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 { val decoded = String(Base64.decode(data, Base64.NO_WRAP), Charsets.UTF_8) decoded.contains(":") && decoded.split(":").all { part -> 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 df8b8c3..a7e7ca5 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 @@ -224,6 +224,9 @@ fun ChatDetailScreen( // 🖼 Состояние для multi-image editor (галерея) var pendingGalleryImages by remember { mutableStateOf>(emptyList()) } + // Триггер для возврата фокуса на инпут после отправки фото из редактора + var inputFocusTrigger by remember { mutableStateOf(0) } + // �📷 Camera launcher val cameraLauncher = rememberLauncherForActivityResult( @@ -1501,7 +1504,9 @@ fun ChatDetailScreen( opponentPublicKey = user.publicKey, myPrivateKey = - currentUserPrivateKey + currentUserPrivateKey, + inputFocusTrigger = + inputFocusTrigger ) } } @@ -1943,6 +1948,7 @@ fun ChatDetailScreen( onMediaSelectedWithCaption = { mediaItem, caption -> // 📸 Отправляем фото с caption напрямую showMediaPicker = false + inputFocusTrigger++ scope.launch { val base64 = MediaUtils.uriToBase64Image( @@ -2253,14 +2259,19 @@ fun ChatDetailScreen( pendingCameraPhotoUri?.let { uri -> ImageEditorScreen( imageUri = uri, - onDismiss = { pendingCameraPhotoUri = null }, + onDismiss = { + pendingCameraPhotoUri = null + inputFocusTrigger++ + }, onSave = { editedUri -> // 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ viewModel.sendImageFromUri(editedUri, "") + showMediaPicker = false }, onSaveWithCaption = { editedUri, caption -> // 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ viewModel.sendImageFromUri(editedUri, caption) + showMediaPicker = false }, isDarkTheme = isDarkTheme, showCaptionInput = true, @@ -2273,7 +2284,10 @@ fun ChatDetailScreen( if (pendingGalleryImages.isNotEmpty()) { MultiImageEditorScreen( imageUris = pendingGalleryImages, - onDismiss = { pendingGalleryImages = emptyList() }, + onDismiss = { + pendingGalleryImages = emptyList() + inputFocusTrigger++ + }, onSendAll = { imagesWithCaptions -> // 🚀 Мгновенный optimistic UI для каждого фото for (imageWithCaption in imagesWithCaptions) { @@ -2282,6 +2296,7 @@ fun ChatDetailScreen( imageWithCaption.caption ) } + showMediaPicker = false }, isDarkTheme = isDarkTheme, recipientName = user.title 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 3fd297d..c016354 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 @@ -20,6 +20,8 @@ import java.util.UUID import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import org.json.JSONArray import org.json.JSONObject @@ -36,7 +38,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { companion object { private const val TAG = "ChatViewModel" 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 // 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List) @@ -499,8 +502,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val hasForward = forwardMessages.isNotEmpty() 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 _opponentTyping.value = false typingTimeoutJob?.cancel() @@ -537,9 +556,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Подписываемся на онлайн статус subscribeToOnlineStatus() - // 🔥 ОПТИМИЗАЦИЯ: Загружаем сообщения ПОСЛЕ задержки для плавной анимации - // 250ms - это время анимации перехода в чат - loadMessagesFromDatabase(delayMs = 250L) + // � P1.2: Загружаем сообщения СРАЗУ — параллельно с анимацией SwipeBackContainer + loadMessagesFromDatabase(delayMs = 0L) } /** @@ -651,19 +669,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { hasMoreMessages = entities.size >= PAGE_SIZE currentOffset = entities.size - // 🔥 ЧАНКОВАЯ расшифровка - по DECRYPT_CHUNK_SIZE сообщений с yield между - // ними - // Это предотвращает блокировку UI thread - val messages = ArrayList(entities.size) + // � P2.2: ПАРАЛЛЕЛЬНАЯ расшифровка с Semaphore + // Запускаем до DECRYPT_PARALLELISM одновременных расшифровок val reversedEntities = entities.asReversed() - for ((index, entity) in reversedEntities.withIndex()) { - val chatMsg = entityToChatMessage(entity) - messages.add(chatMsg) - - // Каждые DECRYPT_CHUNK_SIZE сообщений даём UI thread "подышать" - if ((index + 1) % DECRYPT_CHUNK_SIZE == 0) { - yield() // Позволяем другим корутинам выполниться - } + val semaphore = Semaphore(DECRYPT_PARALLELISM) + val messages = coroutineScope { + reversedEntities + .map { entity -> + async { + semaphore.withPermit { entityToChatMessage(entity) } + } + } + .awaitAll() } // 🔥 Сохраняем в кэш для мгновенной повторной загрузки! @@ -752,9 +769,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val newEntities = entities.filter { it.messageId !in existingIds } if (newEntities.isNotEmpty()) { - val newMessages = ArrayList(newEntities.size) - for (entity in newEntities) { - newMessages.add(entityToChatMessage(entity)) + val semaphore = Semaphore(DECRYPT_PARALLELISM) + val newMessages = coroutineScope { + newEntities + .map { entity -> + async { semaphore.withPermit { entityToChatMessage(entity) } } + } + .awaitAll() } // 🔥 ДОБАВЛЯЕМ новые к текущим, а не заменяем! @@ -840,8 +861,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { currentOffset += entities.size if (entities.isNotEmpty()) { + val semaphore = Semaphore(DECRYPT_PARALLELISM) 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 { + // 🚀 P2.1: Проверяем кэш расшифрованного текста по messageId + val cachedText = decryptionCache[entity.messageId] + // Расшифровываем сообщение из content + chachaKey + var plainKeyAndNonce: ByteArray? = null // 🚀 P2.3: Сохраняем для reply расшифровки 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 - ) - decrypted - } else { - // Fallback на расшифровку plainMessage с приватным ключом - if (privateKey != null && entity.plainMessage.isNotEmpty()) { - try { - CryptoManager.decryptWithPassword(entity.plainMessage, privateKey) - ?: entity.plainMessage - } catch (e: Exception) { + if (cachedText != null) { + cachedText + } else + try { + val privateKey = myPrivateKey + if (privateKey != null && + entity.content.isNotEmpty() && + entity.chachaKey.isNotEmpty() + ) { + // Расшифровываем как в архиве: content + chachaKey + privateKey + // 🚀 Используем Full версию чтобы получить plainKeyAndNonce для + // reply + val decryptResult = + MessageCrypto.decryptIncomingFull( + ciphertext = entity.content, + encryptedKey = entity.chachaKey, + myPrivateKey = privateKey + ) + plainKeyAndNonce = decryptResult.plainKeyAndNonce + val decrypted = decryptResult.plaintext + // 🚀 Сохраняем в кэш + 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 } - } 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 (цитата) - // 🔥 ВАЖНО: Передаем content и chachaKey для расшифровки reply blob если нужно + // � P2.3: Передаём plainKeyAndNonce чтобы избежать повторного ECDH var replyData = parseReplyFromAttachments( attachmentsJson = entity.attachments, isFromMe = entity.fromMe == 1, content = entity.content, - chachaKey = entity.chachaKey + chachaKey = entity.chachaKey, + plainKeyAndNonce = plainKeyAndNonce ) // Если не нашли reply в attachments, пробуем распарсить из текста @@ -1063,7 +1119,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { attachmentsJson: String, isFromMe: Boolean, content: String, - chachaKey: String + chachaKey: String, + plainKeyAndNonce: ByteArray? = + null // 🚀 P2.3: Переиспользуем ключ из основной расшифровки ): ReplyData? { if (attachmentsJson.isEmpty() || attachmentsJson == "[]") { @@ -1133,8 +1191,23 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } catch (e: Exception) {} } - // 🔥 Способ 3: Пробуем decryptReplyBlob с plainKeyAndNonce + // � Способ 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 && + plainKeyAndNonce == null && content.isNotEmpty() && chachaKey.isNotEmpty() && privateKey != null @@ -1146,9 +1219,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { chachaKey, privateKey ) - val plainKeyAndNonce = decryptResult.plainKeyAndNonce + val keyAndNonce = decryptResult.plainKeyAndNonce val decrypted = - MessageCrypto.decryptReplyBlob(dataJson, plainKeyAndNonce) + MessageCrypto.decryptReplyBlob(dataJson, keyAndNonce) if (decrypted.isNotEmpty()) { dataJson = decrypted decryptionSuccess = true diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 462a789..68a1632 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -85,7 +85,8 @@ fun MessageInputBar( onAttachClick: () -> Unit = {}, myPublicKey: String = "", opponentPublicKey: String = "", - myPrivateKey: String = "" + myPrivateKey: String = "", + inputFocusTrigger: Int = 0 ) { val hasReply = replyMessages.isNotEmpty() 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 var isKeyboardVisible by remember { mutableStateOf(false) } var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt index 5b845f1..3a1e386 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt @@ -126,12 +126,15 @@ class AppleEmojiEditTextView @JvmOverloads constructor( fun setTextWithEmojis(newText: String) { if (newText == text.toString()) return isUpdating = true - val cursorPos = selectionStart - setText(newText) - // Восстанавливаем позицию курсора в конец текста - val newCursorPos = if (cursorPos >= 0) newText.length else cursorPos - setSelection(newCursorPos.coerceIn(0, newText.length)) - isUpdating = false + try { + val cursorPos = selectionStart + setText(newText) + // Восстанавливаем позицию курсора в конец текста + val newCursorPos = if (cursorPos >= 0) newText.length else cursorPos + setSelection(newCursorPos.coerceIn(0, newText.length)) + } finally { + isUpdating = false + } replaceEmojisWithImages(editableText) } @@ -297,6 +300,10 @@ fun AppleEmojiTextField( } }, 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) { view.setTextWithEmojis(value) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiCache.kt b/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiCache.kt index 38f6428..8c13ff5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiCache.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiCache.kt @@ -32,7 +32,7 @@ object OptimizedEmojiCache { private set private const val PRELOAD_COUNT = 200 // Предзагружаем первые 200 эмодзи - private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) /** * Предзагрузка при старте приложения