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()
|
val dialogs: StateFlow<List<Dialog>> = _dialogs.asStateFlow()
|
||||||
|
|
||||||
// 🔔 События новых сообщений для обновления UI в реальном времени
|
// 🔔 События новых сообщений для обновления 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()
|
val newMessageEvents: SharedFlow<String> = _newMessageEvents.asSharedFlow()
|
||||||
|
|
||||||
// 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы
|
// 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы
|
||||||
|
|||||||
@@ -401,9 +401,11 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Telegram-style: Прокрутка при новых сообщениях
|
// Telegram-style: Прокрутка при новых сообщениях
|
||||||
// Всегда скроллим к последнему при изменении количества сообщений
|
// 🔥 Добавлен debounce для защиты от спама - ждём 50ms перед скроллом
|
||||||
|
// Это предотвращает создание множества параллельных анимаций при быстром добавлении сообщений
|
||||||
LaunchedEffect(messages.size) {
|
LaunchedEffect(messages.size) {
|
||||||
if (messages.isNotEmpty()) {
|
if (messages.isNotEmpty()) {
|
||||||
|
delay(50) // Debounce - ждём стабилизации
|
||||||
// Всегда скроллим вниз при новом сообщении
|
// Всегда скроллим вниз при новом сообщении
|
||||||
listState.animateScrollToItem(0)
|
listState.animateScrollToItem(0)
|
||||||
wasManualScroll = false
|
wasManualScroll = false
|
||||||
|
|||||||
@@ -38,11 +38,26 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
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 = 5 // Расшифровываем по 5 сообщений за раз
|
||||||
|
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
|
||||||
|
|
||||||
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
|
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
|
||||||
// Сделан глобальным чтобы можно было очистить при удалении диалога
|
// Сделан глобальным чтобы можно было очистить при удалении диалога
|
||||||
private val dialogMessagesCache = ConcurrentHashMap<String, 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
|
private var isSending = false
|
||||||
|
|
||||||
|
// 🔥 Throttling для отправки сообщений (защита от спама)
|
||||||
|
private var lastMessageSentTime = 0L
|
||||||
|
private val MESSAGE_THROTTLE_MS = 100L // Минимум 100ms между сообщениями
|
||||||
|
|
||||||
// Job для отмены загрузки при смене диалога
|
// Job для отмены загрузки при смене диалога
|
||||||
private var loadingJob: Job? = null
|
private var loadingJob: Job? = null
|
||||||
|
|
||||||
@@ -156,11 +175,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
setupNewMessageListener()
|
setupNewMessageListener()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 Debounce для защиты от спама входящих сообщений
|
||||||
|
private var pendingMessageUpdates = mutableSetOf<String>()
|
||||||
|
private var messageUpdateJob: kotlinx.coroutines.Job? = null
|
||||||
|
private val MESSAGE_BATCH_DELAY_MS = 100L // Собираем сообщения за 100ms и обрабатываем пачкой
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔔 Подписка на события новых сообщений от MessageRepository
|
* 🔔 Подписка на события новых сообщений от MessageRepository
|
||||||
* Обновляет UI в реальном времени когда приходит новое сообщение
|
* Обновляет UI в реальном времени когда приходит новое сообщение
|
||||||
* 🚀 ОПТИМИЗАЦИЯ: Добавляем сообщение инкрементально, а не перезагружаем весь список
|
* 🚀 ОПТИМИЗАЦИЯ: Добавляем сообщение инкрементально, а не перезагружаем весь список
|
||||||
* Это предотвращает "прыгание" пузырьков при добавлении нового сообщения
|
* 🔥 ЗАЩИТА ОТ СПАМА: Используем debounce + batching чтобы не перегружать UI при спаме
|
||||||
*/
|
*/
|
||||||
private fun setupNewMessageListener() {
|
private fun setupNewMessageListener() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -171,69 +195,99 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val currentDialogKey = getDialogKey(account, opponent)
|
val currentDialogKey = getDialogKey(account, opponent)
|
||||||
|
|
||||||
if (dialogKey == currentDialogKey) {
|
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) {
|
private suspend fun processPendingMessages(account: String) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
val dialogsToProcess: Set<String>
|
||||||
try {
|
synchronized(pendingMessageUpdates) {
|
||||||
// 📁 Проверяем является ли это Saved Messages
|
dialogsToProcess = pendingMessageUpdates.toSet()
|
||||||
val opponent = opponentKey ?: return@launch
|
pendingMessageUpdates.clear()
|
||||||
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 // Сообщение уже есть, не добавляем
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Конвертируем в ChatMessage
|
for (dialogKey in dialogsToProcess) {
|
||||||
val newMessage = entityToChatMessage(latestEntity)
|
addLatestMessagesFromDb(account, dialogKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🚀 КЛЮЧЕВОЕ: Добавляем в конец списка (новые сообщения = больший timestamp)
|
/**
|
||||||
// НЕ пересортировываем весь список - просто добавляем!
|
* 🔥 Загрузка ВСЕХ новых сообщений за раз (batch)
|
||||||
withContext(Dispatchers.Main.immediate) {
|
* Вместо загрузки по одному - загружаем все что появились с момента последнего обновления
|
||||||
|
*/
|
||||||
|
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
|
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) {
|
if (isDialogActive) {
|
||||||
messageDao.markDialogAsRead(account, dialogKey)
|
messageDao.markDialogAsRead(account, dialogKey)
|
||||||
// Отправляем read receipt (НЕ для saved messages!)
|
val hasIncoming = newMessages.any { !it.isOutgoing }
|
||||||
if (!newMessage.isOutgoing && !isSavedMessages) {
|
if (hasIncoming && !isSavedMessages) {
|
||||||
sendReadReceiptToOpponent()
|
sendReadReceiptToOpponent()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем диалог - используем специальный метод для saved messages
|
// Обновляем диалог
|
||||||
if (isSavedMessages) {
|
if (isSavedMessages) {
|
||||||
dialogDao.updateSavedMessagesDialogFromMessages(account)
|
dialogDao.updateSavedMessagesDialogFromMessages(account)
|
||||||
} else {
|
} else {
|
||||||
dialogDao.updateDialogFromMessages(account, opponent)
|
dialogDao.updateDialogFromMessages(account, opponent)
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} 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 - пользователь видит сообщения мгновенно
|
// 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно
|
||||||
// НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД
|
// НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД
|
||||||
@@ -588,15 +642,26 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// Сортируем по timestamp чтобы новые были в конце
|
// Сортируем по timestamp чтобы новые были в конце
|
||||||
val updatedMessages = (currentMessages + newMessages).sortedBy { it.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) {
|
withContext(Dispatchers.Main.immediate) {
|
||||||
_messages.value = updatedMessages
|
_messages.value = updatedMessages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasMoreMessages = entities.size >= PAGE_SIZE
|
hasMoreMessages = entities.size >= PAGE_SIZE
|
||||||
|
// 🔥 ИСПРАВЛЕНИЕ: НЕ сбрасываем offset если уже загружено больше сообщений!
|
||||||
|
// Это предотвращает потерю прогресса пагинации при refresh
|
||||||
|
if (currentOffset < entities.size) {
|
||||||
currentOffset = entities.size
|
currentOffset = entities.size
|
||||||
|
}
|
||||||
|
|
||||||
// 👁️ Фоновые операции - НЕ помечаем как прочитанные если диалог неактивен!
|
// 👁️ Фоновые операции - НЕ помечаем как прочитанные если диалог неактивен!
|
||||||
if (isDialogActive) {
|
if (isDialogActive) {
|
||||||
@@ -651,6 +716,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
entityToChatMessage(entity)
|
entityToChatMessage(entity)
|
||||||
}.asReversed()
|
}.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) {
|
withContext(Dispatchers.Main) {
|
||||||
_messages.value = newMessages + _messages.value
|
_messages.value = newMessages + _messages.value
|
||||||
@@ -1170,6 +1244,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 Throttling - защита от спама сообщениями
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastMessageSentTime < MESSAGE_THROTTLE_MS) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastMessageSentTime = now
|
||||||
|
|
||||||
isSending = true
|
isSending = true
|
||||||
|
|
||||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
|
|||||||
Reference in New Issue
Block a user