fix: fix crashes
This commit is contained in:
@@ -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 - предотвращает бесконечные запросы
|
||||
|
||||
@@ -401,9 +401,11 @@ fun ChatDetailScreen(
|
||||
}
|
||||
|
||||
// Telegram-style: Прокрутка при новых сообщениях
|
||||
// Всегда скроллим к последнему при изменении количества сообщений
|
||||
// 🔥 Добавлен debounce для защиты от спама - ждём 50ms перед скроллом
|
||||
// Это предотвращает создание множества параллельных анимаций при быстром добавлении сообщений
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
delay(50) // Debounce - ждём стабилизации
|
||||
// Всегда скроллим вниз при новом сообщении
|
||||
listState.animateScrollToItem(0)
|
||||
wasManualScroll = false
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user