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 8ef36e8..52402e9 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -66,7 +66,12 @@ class MessageRepository private constructor(private val context: Context) { val dialogs: StateFlow> = _dialogs.asStateFlow() // 🔔 События новых сообщений для обновления UI в реальном времени - private val _newMessageEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 10) + // 🔥 Увеличен буфер до 64 + DROP_OLDEST для защиты от переполнения при спаме + private val _newMessageEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST + ) val newMessageEvents: SharedFlow = _newMessageEvents.asSharedFlow() // 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы 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 838588a..5d9cf98 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 @@ -401,9 +401,11 @@ fun ChatDetailScreen( } // Telegram-style: Прокрутка при новых сообщениях - // Всегда скроллим к последнему при изменении количества сообщений + // 🔥 Добавлен debounce для защиты от спама - ждём 50ms перед скроллом + // Это предотвращает создание множества параллельных анимаций при быстром добавлении сообщений LaunchedEffect(messages.size) { if (messages.isNotEmpty()) { + delay(50) // Debounce - ждём стабилизации // Всегда скроллим вниз при новом сообщении listState.animateScrollToItem(0) wasManualScroll = false 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 10b393a..99136de 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 @@ -38,10 +38,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private const val TAG = "ChatViewModel" private const val PAGE_SIZE = 30 private const val DECRYPT_CHUNK_SIZE = 5 // Расшифровываем по 5 сообщений за раз - + private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM + // 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List) // Сделан глобальным чтобы можно было очистить при удалении диалога private val dialogMessagesCache = ConcurrentHashMap>() + + /** + * 🔥 Обновить кэш с ограничением размера + * Сохраняет только последние MAX_CACHE_SIZE сообщений для предотвращения OOM + */ + private fun updateCacheWithLimit(dialogKey: String, messages: List) { + val limitedMessages = if (messages.size > MAX_CACHE_SIZE) { + // Оставляем только последние сообщения (по timestamp) + messages.sortedByDescending { it.timestamp }.take(MAX_CACHE_SIZE).sortedBy { it.timestamp } + } else { + messages + } + dialogMessagesCache[dialogKey] = limitedMessages + } /** * 🗑️ Очистить кэш сообщений для диалога @@ -131,6 +146,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Защита от двойной отправки private var isSending = false + + // 🔥 Throttling для отправки сообщений (защита от спама) + private var lastMessageSentTime = 0L + private val MESSAGE_THROTTLE_MS = 100L // Минимум 100ms между сообщениями // Job для отмены загрузки при смене диалога private var loadingJob: Job? = null @@ -156,11 +175,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { setupNewMessageListener() } + // 🔥 Debounce для защиты от спама входящих сообщений + private var pendingMessageUpdates = mutableSetOf() + private var messageUpdateJob: kotlinx.coroutines.Job? = null + private val MESSAGE_BATCH_DELAY_MS = 100L // Собираем сообщения за 100ms и обрабатываем пачкой + /** * 🔔 Подписка на события новых сообщений от MessageRepository * Обновляет UI в реальном времени когда приходит новое сообщение * 🚀 ОПТИМИЗАЦИЯ: Добавляем сообщение инкрементально, а не перезагружаем весь список - * Это предотвращает "прыгание" пузырьков при добавлении нового сообщения + * 🔥 ЗАЩИТА ОТ СПАМА: Используем debounce + batching чтобы не перегружать UI при спаме */ private fun setupNewMessageListener() { viewModelScope.launch { @@ -169,71 +193,101 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val account = myPublicKey ?: return@collect val opponent = opponentKey ?: return@collect val currentDialogKey = getDialogKey(account, opponent) - + if (dialogKey == currentDialogKey) { - // 🚀 ОПТИМИЗАЦИЯ: Загружаем только последнее сообщение и добавляем инкрементально - addLatestMessageFromDb(account, currentDialogKey) + // 🔥 ЗАЩИТА ОТ СПАМА: Добавляем в очередь и обрабатываем пачкой + synchronized(pendingMessageUpdates) { + pendingMessageUpdates.add(currentDialogKey) + } + + // Отменяем предыдущий job и создаём новый с задержкой + messageUpdateJob?.cancel() + messageUpdateJob = viewModelScope.launch { + kotlinx.coroutines.delay(MESSAGE_BATCH_DELAY_MS) + processPendingMessages(account) + } } } } } - + /** - * 🚀 Инкрементальное добавление последнего сообщения из БД - * Вместо полной перезагрузки списка - добавляем только новое сообщение - * Это предотвращает "прыгание" пузырьков в Compose - * 📁 SAVED MESSAGES: Использует специальные методы для saved messages + * 🔥 Обработка накопленных событий о новых сообщениях + * Загружает все новые сообщения одним запросом вместо многих */ - private fun addLatestMessageFromDb(account: String, dialogKey: String) { - viewModelScope.launch(Dispatchers.IO) { + private suspend fun processPendingMessages(account: String) { + val dialogsToProcess: Set + synchronized(pendingMessageUpdates) { + dialogsToProcess = pendingMessageUpdates.toSet() + pendingMessageUpdates.clear() + } + + for (dialogKey in dialogsToProcess) { + addLatestMessagesFromDb(account, dialogKey) + } + } + + /** + * 🔥 Загрузка ВСЕХ новых сообщений за раз (batch) + * Вместо загрузки по одному - загружаем все что появились с момента последнего обновления + */ + private suspend fun addLatestMessagesFromDb(account: String, dialogKey: String) { + kotlinx.coroutines.withContext(Dispatchers.IO) { try { - // 📁 Проверяем является ли это Saved Messages - val opponent = opponentKey ?: return@launch + val opponent = opponentKey ?: return@withContext val isSavedMessages = (opponent == account) - - // Получаем последнее сообщение из БД - val latestEntity = if (isSavedMessages) { - messageDao.getMessagesForSavedDialog(account, limit = 1, offset = 0).firstOrNull() + + // Получаем последние N сообщений из БД (больше чем 1, чтобы поймать весь спам) + val latestEntities = if (isSavedMessages) { + messageDao.getMessagesForSavedDialog(account, limit = 20, offset = 0) } else { - messageDao.getMessages(account, dialogKey, limit = 1, offset = 0).firstOrNull() - } ?: return@launch - - // Проверяем, есть ли это сообщение уже в списке + messageDao.getMessages(account, dialogKey, limit = 20, offset = 0) + } + + if (latestEntities.isEmpty()) return@withContext + + // Фильтруем только новые сообщения val existingIds = _messages.value.map { it.id }.toSet() - if (latestEntity.messageId in existingIds) { - return@launch // Сообщение уже есть, не добавляем - } - - // Конвертируем в ChatMessage - val newMessage = entityToChatMessage(latestEntity) - - // 🚀 КЛЮЧЕВОЕ: Добавляем в конец списка (новые сообщения = больший timestamp) - // НЕ пересортировываем весь список - просто добавляем! - withContext(Dispatchers.Main.immediate) { + val newEntities = latestEntities.filter { it.messageId !in existingIds } + + if (newEntities.isEmpty()) return@withContext + + // Конвертируем все новые сообщения + val newMessages = newEntities.map { entityToChatMessage(it) } + + // Добавляем все сразу + kotlinx.coroutines.withContext(Dispatchers.Main.immediate) { val currentList = _messages.value - _messages.value = currentList + newMessage + _messages.value = (currentList + newMessages).sortedBy { it.timestamp } } - + // Обновляем кэш - dialogMessagesCache[dialogKey] = _messages.value - - // 👁️ Фоновые операции + val cachedMessages = dialogMessagesCache[dialogKey] + if (cachedMessages != null) { + updateCacheWithLimit(dialogKey, cachedMessages + newMessages) + } else { + updateCacheWithLimit(dialogKey, _messages.value) + } + + currentOffset += newMessages.size + + // Read receipts if (isDialogActive) { messageDao.markDialogAsRead(account, dialogKey) - // Отправляем read receipt (НЕ для saved messages!) - if (!newMessage.isOutgoing && !isSavedMessages) { + val hasIncoming = newMessages.any { !it.isOutgoing } + if (hasIncoming && !isSavedMessages) { sendReadReceiptToOpponent() } } - - // Обновляем диалог - используем специальный метод для saved messages + + // Обновляем диалог if (isSavedMessages) { dialogDao.updateSavedMessagesDialogFromMessages(account) } else { dialogDao.updateDialogFromMessages(account, opponent) } - } catch (e: Exception) { + // Ignore errors during batch processing } } } @@ -504,10 +558,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } - + // 🔥 Сохраняем в кэш для мгновенной повторной загрузки! - dialogMessagesCache[dialogKey] = messages.toList() - + updateCacheWithLimit(dialogKey, messages.toList()) + // 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно // НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД withContext(Dispatchers.Main.immediate) { @@ -587,17 +641,28 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 🔥 ДОБАВЛЯЕМ новые к текущим, а не заменяем! // Сортируем по timestamp чтобы новые были в конце val updatedMessages = (currentMessages + newMessages).sortedBy { it.timestamp } - - // Обновляем кэш и UI - dialogMessagesCache[dialogKey] = updatedMessages + + // 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш сохраняя ВСЕ сообщения, не только отображаемые! + // Объединяем существующий кэш с новыми сообщениями + val existingCache = dialogMessagesCache[dialogKey] ?: emptyList() + val allCachedIds = existingCache.map { it.id }.toSet() + val trulyNewMessages = newMessages.filter { it.id !in allCachedIds } + if (trulyNewMessages.isNotEmpty()) { + updateCacheWithLimit(dialogKey, (existingCache + trulyNewMessages).sortedBy { it.timestamp }) + } + withContext(Dispatchers.Main.immediate) { _messages.value = updatedMessages } } hasMoreMessages = entities.size >= PAGE_SIZE - currentOffset = entities.size - + // 🔥 ИСПРАВЛЕНИЕ: НЕ сбрасываем offset если уже загружено больше сообщений! + // Это предотвращает потерю прогресса пагинации при refresh + if (currentOffset < entities.size) { + currentOffset = entities.size + } + // 👁️ Фоновые операции - НЕ помечаем как прочитанные если диалог неактивен! if (isDialogActive) { messageDao.markDialogAsRead(account, dialogKey) @@ -650,13 +715,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val newMessages = entities.map { entity -> entityToChatMessage(entity) }.asReversed() - + + // 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш при загрузке старых сообщений! + // Это предотвращает потерю сообщений при повторном открытии диалога + val existingCache = dialogMessagesCache[dialogKey] ?: emptyList() + val allCachedIds = existingCache.map { it.id }.toSet() + val trulyNewMessages = newMessages.filter { it.id !in allCachedIds } + if (trulyNewMessages.isNotEmpty()) { + updateCacheWithLimit(dialogKey, (trulyNewMessages + existingCache).sortedBy { it.timestamp }) + } + // Добавляем в начало списка (старые сообщения) withContext(Dispatchers.Main) { _messages.value = newMessages + _messages.value } } - + withContext(Dispatchers.Main) { _isLoadingMore.value = false } @@ -1169,12 +1243,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (isSending) { return } - + + // 🔥 Throttling - защита от спама сообщениями + val now = System.currentTimeMillis() + if (now - lastMessageSentTime < MESSAGE_THROTTLE_MS) { + return + } + lastMessageSentTime = now + isSending = true - + val messageId = UUID.randomUUID().toString().replace("-", "").take(32) val timestamp = System.currentTimeMillis() - + // 🔥 Формируем ReplyData для отображения в UI (только первое сообщение) // Работает и для reply, и для forward val replyData: ReplyData? = if (replyMsgs.isNotEmpty()) {