fix: fix crashes

This commit is contained in:
k1ngsterr1
2026-02-02 05:20:27 +05:00
parent 7cf20429a5
commit 311144ff4d
3 changed files with 145 additions and 57 deletions

View File

@@ -66,7 +66,12 @@ class MessageRepository private constructor(private val context: Context) {
val dialogs: StateFlow<List<Dialog>> = _dialogs.asStateFlow()
// 🔔 События новых сообщений для обновления UI в реальном времени
private val _newMessageEvents = MutableSharedFlow<String>(replay = 0, extraBufferCapacity = 10)
// 🔥 Увеличен буфер до 64 + DROP_OLDEST для защиты от переполнения при спаме
private val _newMessageEvents = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
)
val newMessageEvents: SharedFlow<String> = _newMessageEvents.asSharedFlow()
// 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы

View File

@@ -401,9 +401,11 @@ fun ChatDetailScreen(
}
// Telegram-style: Прокрутка при новых сообщениях
// Всегда скроллим к последнему при изменении количества сообщений
// 🔥 Добавлен debounce для защиты от спама - ждём 50ms перед скроллом
// Это предотвращает создание множества параллельных анимаций при быстром добавлении сообщений
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
delay(50) // Debounce - ждём стабилизации
// Всегда скроллим вниз при новом сообщении
listState.animateScrollToItem(0)
wasManualScroll = false

View File

@@ -38,11 +38,26 @@ 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<ChatMessage>)
// Сделан глобальным чтобы можно было очистить при удалении диалога
private val dialogMessagesCache = ConcurrentHashMap<String, List<ChatMessage>>()
/**
* 🔥 Обновить кэш с ограничением размера
* Сохраняет только последние MAX_CACHE_SIZE сообщений для предотвращения OOM
*/
private fun updateCacheWithLimit(dialogKey: String, messages: List<ChatMessage>) {
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
}
/**
* 🗑️ Очистить кэш сообщений для диалога
* Вызывается при удалении диалога
@@ -132,6 +147,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<String>()
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 {
@@ -171,69 +195,99 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
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) {
try {
// 📁 Проверяем является ли это Saved Messages
val opponent = opponentKey ?: return@launch
val isSavedMessages = (opponent == account)
// Получаем последнее сообщение из БД
val latestEntity = if (isSavedMessages) {
messageDao.getMessagesForSavedDialog(account, limit = 1, offset = 0).firstOrNull()
} else {
messageDao.getMessages(account, dialogKey, limit = 1, offset = 0).firstOrNull()
} ?: return@launch
// Проверяем, есть ли это сообщение уже в списке
val existingIds = _messages.value.map { it.id }.toSet()
if (latestEntity.messageId in existingIds) {
return@launch // Сообщение уже есть, не добавляем
private suspend fun processPendingMessages(account: String) {
val dialogsToProcess: Set<String>
synchronized(pendingMessageUpdates) {
dialogsToProcess = pendingMessageUpdates.toSet()
pendingMessageUpdates.clear()
}
// Конвертируем в ChatMessage
val newMessage = entityToChatMessage(latestEntity)
for (dialogKey in dialogsToProcess) {
addLatestMessagesFromDb(account, dialogKey)
}
}
// 🚀 КЛЮЧЕВОЕ: Добавляем в конец списка (новые сообщения = больший timestamp)
// НЕ пересортировываем весь список - просто добавляем!
withContext(Dispatchers.Main.immediate) {
/**
* 🔥 Загрузка ВСЕХ новых сообщений за раз (batch)
* Вместо загрузки по одному - загружаем все что появились с момента последнего обновления
*/
private suspend fun addLatestMessagesFromDb(account: String, dialogKey: String) {
kotlinx.coroutines.withContext(Dispatchers.IO) {
try {
val opponent = opponentKey ?: return@withContext
val isSavedMessages = (opponent == account)
// Получаем последние N сообщений из БД (больше чем 1, чтобы поймать весь спам)
val latestEntities = if (isSavedMessages) {
messageDao.getMessagesForSavedDialog(account, limit = 20, offset = 0)
} else {
messageDao.getMessages(account, dialogKey, limit = 20, offset = 0)
}
if (latestEntities.isEmpty()) return@withContext
// Фильтруем только новые сообщения
val existingIds = _messages.value.map { it.id }.toSet()
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
}
}
}
@@ -506,7 +560,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 Сохраняем в кэш для мгновенной повторной загрузки!
dialogMessagesCache[dialogKey] = messages.toList()
updateCacheWithLimit(dialogKey, messages.toList())
// 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно
// НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД
@@ -588,15 +642,26 @@ 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
// 🔥 ИСПРАВЛЕНИЕ: НЕ сбрасываем offset если уже загружено больше сообщений!
// Это предотвращает потерю прогресса пагинации при refresh
if (currentOffset < entities.size) {
currentOffset = entities.size
}
// 👁️ Фоновые операции - НЕ помечаем как прочитанные если диалог неактивен!
if (isDialogActive) {
@@ -651,6 +716,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
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
@@ -1170,6 +1244,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
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)