From 11a8ff764491db15430f4f95fad570520bd581b6 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 8 Feb 2026 07:34:25 +0500 Subject: [PATCH] Refactor SwipeBackContainer for improved performance and readability - Added lazy composition to skip setup until the screen is first opened, reducing allocations. - Cleaned up code formatting for better readability. - Enhanced comments for clarity on functionality. - Streamlined gesture handling logic for swipe detection and animation. --- .../messenger/data/MessageRepository.kt | 1077 ++++++++--------- .../messenger/database/MessageEntities.kt | 986 +++++++-------- .../messenger/database/RosettaDatabase.kt | 203 ++-- .../messenger/ui/chats/ChatsListViewModel.kt | 838 +++++++------ .../ui/components/SwipeBackContainer.kt | 319 ++--- 5 files changed, 1744 insertions(+), 1679 deletions(-) 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 3bd787b..94ab4f3 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -8,177 +8,159 @@ import com.rosetta.messenger.network.* import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.MessageLogger +import java.util.UUID import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import org.json.JSONArray import org.json.JSONObject -import java.util.UUID -/** - * UI модель сообщения - */ +/** UI модель сообщения */ data class Message( - val id: Long = 0, - val messageId: String, - val fromPublicKey: String, - val toPublicKey: String, - val content: String, // Расшифрованный текст - val timestamp: Long, - val isFromMe: Boolean, - val isRead: Boolean, - val deliveryStatus: DeliveryStatus, - val attachments: List = emptyList(), - val replyToMessageId: String? = null + val id: Long = 0, + val messageId: String, + val fromPublicKey: String, + val toPublicKey: String, + val content: String, // Расшифрованный текст + val timestamp: Long, + val isFromMe: Boolean, + val isRead: Boolean, + val deliveryStatus: DeliveryStatus, + val attachments: List = emptyList(), + val replyToMessageId: String? = null ) -/** - * UI модель диалога - */ +/** UI модель диалога */ data class Dialog( - val opponentKey: String, - val opponentTitle: String, - val opponentUsername: String, - val lastMessage: String, - val lastMessageTimestamp: Long, - val unreadCount: Int, - val isOnline: Boolean, - val lastSeen: Long, - val verified: Boolean + val opponentKey: String, + val opponentTitle: String, + val opponentUsername: String, + val lastMessage: String, + val lastMessageTimestamp: Long, + val unreadCount: Int, + val isOnline: Boolean, + val lastSeen: Long, + val verified: Boolean ) -/** - * Repository для работы с сообщениями - * Оптимизированная версия с кэшированием и Optimistic UI - */ +/** Repository для работы с сообщениями Оптимизированная версия с кэшированием и Optimistic UI */ class MessageRepository private constructor(private val context: Context) { - + private val database = RosettaDatabase.getDatabase(context) private val messageDao = database.messageDao() private val dialogDao = database.dialogDao() private val avatarDao = database.avatarDao() - + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - + // Кэш сообщений по диалогам private val messageCache = mutableMapOf>>() - + // Кэш диалогов private val _dialogs = MutableStateFlow>(emptyList()) val dialogs: StateFlow> = _dialogs.asStateFlow() - + // 🔔 События новых сообщений для обновления UI в реальном времени // 🔥 Увеличен буфер до 64 + DROP_OLDEST для защиты от переполнения при спаме - private val _newMessageEvents = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 64, - onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST - ) + private val _newMessageEvents = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST + ) val newMessageEvents: SharedFlow = _newMessageEvents.asSharedFlow() // 🔔 События обновления статуса доставки для UI data class DeliveryStatusUpdate( - val dialogKey: String, - val messageId: String, - val status: DeliveryStatus + val dialogKey: String, + val messageId: String, + val status: DeliveryStatus ) - private val _deliveryStatusEvents = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 64, - onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST - ) - val deliveryStatusEvents: SharedFlow = _deliveryStatusEvents.asSharedFlow() + private val _deliveryStatusEvents = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST + ) + val deliveryStatusEvents: SharedFlow = + _deliveryStatusEvents.asSharedFlow() // 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы private val requestedUserInfoKeys = mutableSetOf() - + // Текущий аккаунт private var currentAccount: String? = null private var currentPrivateKey: String? = null - + companion object { - @Volatile - private var INSTANCE: MessageRepository? = null + @Volatile private var INSTANCE: MessageRepository? = null // 🔥 In-memory кэш обработанных messageId для предотвращения дубликатов // LRU кэш с ограничением 1000 элементов - защита от race conditions - private val processedMessageIds = java.util.Collections.synchronizedSet( - object : LinkedHashSet() { - override fun add(element: String): Boolean { - if (size >= 1000) remove(first()) - return super.add(element) - } - } - ) + private val processedMessageIds = + java.util.Collections.synchronizedSet( + object : LinkedHashSet() { + override fun add(element: String): Boolean { + if (size >= 1000) remove(first()) + return super.add(element) + } + } + ) /** - * Помечает messageId как обработанный и возвращает true если это новый ID - * Возвращает false если сообщение уже было обработано (дубликат) + * Помечает messageId как обработанный и возвращает true если это новый ID Возвращает false + * если сообщение уже было обработано (дубликат) */ fun markAsProcessed(messageId: String): Boolean = processedMessageIds.add(messageId) - /** - * Очистка кэша (вызывается при logout) - */ + /** Очистка кэша (вызывается при logout) */ fun clearProcessedCache() = processedMessageIds.clear() fun getInstance(context: Context): MessageRepository { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: MessageRepository(context.applicationContext).also { INSTANCE = it } - } + return INSTANCE + ?: synchronized(this) { + INSTANCE + ?: MessageRepository(context.applicationContext).also { + INSTANCE = it + } + } } - + /** - * Генерация уникального messageId - * 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного хэша, - * чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями) + * Генерация уникального messageId 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного + * хэша, чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями) */ fun generateMessageId(fromPublicKey: String, toPublicKey: String, timestamp: Long): String { // Генерируем UUID для гарантии уникальности return UUID.randomUUID().toString().replace("-", "").take(32) } } - - /** - * Инициализация с текущим аккаунтом - */ + + /** Инициализация с текущим аккаунтом */ fun initialize(publicKey: String, privateKey: String) { val start = System.currentTimeMillis() -// 🔥 Очищаем кэш запрошенных user info при смене аккаунта + // 🔥 Очищаем кэш запрошенных user info при смене аккаунта if (currentAccount != publicKey) { requestedUserInfoKeys.clear() -} - + } + currentAccount = publicKey currentPrivateKey = privateKey -// Загрузка диалогов - scope.launch { - dialogDao.getDialogsFlow(publicKey).collect { entities -> - _dialogs.value = entities.map { it.toDialog() } - - // 🔥 Запрашиваем информацию о пользователях, у которых нет имени - entities.forEach { dialog -> - if (dialog.opponentTitle.isBlank() || dialog.opponentTitle == dialog.opponentKey.take(7)) { - requestUserInfo(dialog.opponentKey) - } - } - } - } + // 🚀 ОПТИМИЗАЦИЯ: Убрана дублирующая подписка на dialogDao.getDialogsFlow() + // Подписка на диалоги и загрузка user-info уже выполняется в + // ChatsListViewModel.setAccount() + // Дублирование вызывало двойную обработку каждого обновления таблицы dialogs } - - /** - * Проверка инициализации - */ + + /** Проверка инициализации */ fun isInitialized(): Boolean { return currentAccount != null && currentPrivateKey != null } - - /** - * Получить поток сообщений для диалога - */ + + /** Получить поток сообщений для диалога */ fun getMessagesFlow(opponentKey: String): StateFlow> { val dialogKey = getDialogKey(opponentKey) - + return messageCache.getOrPut(dialogKey) { MutableStateFlow>(emptyList()).also { flow -> scope.launch { @@ -191,213 +173,227 @@ class MessageRepository private constructor(private val context: Context) { } } } - - /** - * Отправка сообщения с Optimistic UI - * Возвращает сразу, шифрование и отправка в фоне - */ + + /** Отправка сообщения с Optimistic UI Возвращает сразу, шифрование и отправка в фоне */ suspend fun sendMessage( - toPublicKey: String, - text: String, - attachments: List = emptyList(), - replyToMessageId: String? = null + toPublicKey: String, + text: String, + attachments: List = emptyList(), + replyToMessageId: String? = null ): Message { val account = currentAccount ?: throw IllegalStateException("Not initialized") val privateKey = currentPrivateKey ?: throw IllegalStateException("Not initialized") - + val messageId = UUID.randomUUID().toString().replace("-", "").take(32) val timestamp = System.currentTimeMillis() val dialogKey = getDialogKey(toPublicKey) - + // 📁 Проверяем является ли это Saved Messages val isSavedMessages = (account == toPublicKey) - + // 📝 LOG: Начало отправки MessageLogger.logSendStart( - messageId = messageId, - toPublicKey = toPublicKey, - textLength = text.trim().length, - attachmentsCount = attachments.size, - isSavedMessages = isSavedMessages, - replyToMessageId = replyToMessageId + messageId = messageId, + toPublicKey = toPublicKey, + textLength = text.trim().length, + attachmentsCount = attachments.size, + isSavedMessages = isSavedMessages, + replyToMessageId = replyToMessageId ) - + // 1. Создаем оптимистичное сообщение // 📁 Для saved messages - сразу DELIVERED и прочитано - val optimisticMessage = Message( - messageId = messageId, - fromPublicKey = account, - toPublicKey = toPublicKey, - content = text.trim(), - timestamp = timestamp, - isFromMe = true, - isRead = isSavedMessages, // 📁 Если сам себе - сразу прочитано - deliveryStatus = if (isSavedMessages) DeliveryStatus.DELIVERED else DeliveryStatus.WAITING, // 📁 Для saved messages - сразу доставлено - attachments = attachments, - replyToMessageId = replyToMessageId - ) - + val optimisticMessage = + Message( + messageId = messageId, + fromPublicKey = account, + toPublicKey = toPublicKey, + content = text.trim(), + timestamp = timestamp, + isFromMe = true, + isRead = isSavedMessages, // 📁 Если сам себе - сразу прочитано + deliveryStatus = + if (isSavedMessages) DeliveryStatus.DELIVERED + else DeliveryStatus.WAITING, // 📁 Для saved messages - сразу + // доставлено + attachments = attachments, + replyToMessageId = replyToMessageId + ) + // 2. Обновляем UI сразу (Optimistic Update) updateMessageCache(dialogKey, optimisticMessage) MessageLogger.logCacheUpdate(dialogKey, messageCache[dialogKey]?.value?.size ?: 0) - + // 3. Фоновая обработка scope.launch { val startTime = System.currentTimeMillis() try { // Шифрование - val encryptResult = MessageCrypto.encryptForSending( - text.trim(), - toPublicKey - ) + val encryptResult = MessageCrypto.encryptForSending(text.trim(), toPublicKey) val encryptedContent = encryptResult.ciphertext val encryptedKey = encryptResult.encryptedKey - + // 📝 LOG: Шифрование успешно MessageLogger.logEncryptionSuccess( - messageId = messageId, - encryptedContentLength = encryptedContent.length, - encryptedKeyLength = encryptedKey.length + messageId = messageId, + encryptedContentLength = encryptedContent.length, + encryptedKeyLength = encryptedKey.length ) - + // 🔑 КРИТИЧНО: Сохраняем ЗАШИФРОВАННЫЙ chacha_key (как в Desktop!) // Desktop хранит зашифрованный ключ, расшифровывает только при использовании - + // Сериализуем attachments в JSON val attachmentsJson = serializeAttachments(attachments) - + // 🔒 Шифруем plainMessage с использованием приватного ключа - val encryptedPlainMessage = CryptoManager.encryptWithPassword(text.trim(), privateKey) - + val encryptedPlainMessage = + CryptoManager.encryptWithPassword(text.trim(), privateKey) + // ✅ Проверяем существование - не дублируем сообщения val exists = messageDao.messageExists(account, messageId) if (!exists) { // Сохраняем в БД только если сообщения нет // 📁 Для saved messages - сразу read=1 и delivered=DELIVERED - val entity = MessageEntity( - account = account, - fromPublicKey = account, - toPublicKey = toPublicKey, - content = encryptedContent, - timestamp = timestamp, - chachaKey = encryptedKey, - read = if (isSavedMessages) 1 else 0, - fromMe = 1, - delivered = if (isSavedMessages) DeliveryStatus.DELIVERED.value else DeliveryStatus.WAITING.value, - messageId = messageId, - plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст - attachments = attachmentsJson, - replyToMessageId = replyToMessageId, - dialogKey = dialogKey - ) + val entity = + MessageEntity( + account = account, + fromPublicKey = account, + toPublicKey = toPublicKey, + content = encryptedContent, + timestamp = timestamp, + chachaKey = encryptedKey, + read = if (isSavedMessages) 1 else 0, + fromMe = 1, + delivered = + if (isSavedMessages) DeliveryStatus.DELIVERED.value + else DeliveryStatus.WAITING.value, + messageId = messageId, + plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст + attachments = attachmentsJson, + replyToMessageId = replyToMessageId, + dialogKey = dialogKey + ) messageDao.insertMessage(entity) - + // 📝 LOG: Сохранено в БД MessageLogger.logDbSave(messageId, dialogKey, isNew = true) } else { MessageLogger.logDbSave(messageId, dialogKey, isNew = false) } - + // 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages dialogDao.updateDialogFromMessages(account, toPublicKey) - - // 🔥 Логируем что записалось в диалог + + // 🔥 Логируем что записалось в диалог val dialog = dialogDao.getDialog(account, toPublicKey) MessageLogger.logDialogUpdate( - dialogKey = dialogKey, - lastMessage = dialog?.lastMessage, - unreadCount = dialog?.unreadCount ?: 0 + dialogKey = dialogKey, + lastMessage = dialog?.lastMessage, + unreadCount = dialog?.unreadCount ?: 0 ) - - // 🔥 Отмечаем что я отправлял сообщения в этот диалог (перемещает из requests в chats) + + // 🔥 Отмечаем что я отправлял сообщения в этот диалог (перемещает из requests в + // chats) val updatedRows = dialogDao.markIHaveSent(account, toPublicKey) - + // 📁 НЕ отправляем пакет на сервер для saved messages! // Как в Архиве: if(publicKey == opponentPublicKey) return; if (isSavedMessages) { MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime) MessageLogger.debug("📁 SavedMessages: skipping server send") - return@launch // Для saved messages - только локальное сохранение, без отправки на сервер + return@launch // Для saved messages - только локальное сохранение, без отправки + // на сервер } - + // Отправляем пакет (только для обычных диалогов) - val packet = PacketMessage().apply { - this.fromPublicKey = account - this.toPublicKey = toPublicKey - this.content = encryptedContent - this.chachaKey = encryptedKey - this.timestamp = timestamp - this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey) - this.messageId = messageId - this.attachments = attachments - } - + val packet = + PacketMessage().apply { + this.fromPublicKey = account + this.toPublicKey = toPublicKey + this.content = encryptedContent + this.chachaKey = encryptedKey + this.timestamp = timestamp + this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey) + this.messageId = messageId + this.attachments = attachments + } + // 📝 LOG: Отправка пакета MessageLogger.logPacketSend(messageId, toPublicKey, timestamp) - + ProtocolManager.send(packet) - + // 📝 LOG: Успешная отправка MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime) - } catch (e: Exception) { // 📝 LOG: Ошибка отправки MessageLogger.logSendError(messageId, e) - + // При ошибке обновляем статус messageDao.updateDeliveryStatus(account, messageId, DeliveryStatus.ERROR.value) updateMessageStatus(dialogKey, messageId, DeliveryStatus.ERROR) } } - + return optimisticMessage } - - /** - * Обработка входящего сообщения - */ + + /** Обработка входящего сообщения */ suspend fun handleIncomingMessage(packet: PacketMessage) { val startTime = System.currentTimeMillis() - - val account = currentAccount ?: run { - MessageLogger.debug("📥 RECEIVE SKIP: account is null") - return - } - val privateKey = currentPrivateKey ?: run { - MessageLogger.debug("📥 RECEIVE SKIP: privateKey is null") - return - } - + + val account = + currentAccount + ?: run { + MessageLogger.debug("📥 RECEIVE SKIP: account is null") + return + } + val privateKey = + currentPrivateKey + ?: run { + MessageLogger.debug("📥 RECEIVE SKIP: privateKey is null") + return + } + // 📝 LOG: Начало обработки входящего сообщения MessageLogger.logReceiveStart( - messageId = packet.messageId, - fromPublicKey = packet.fromPublicKey, - toPublicKey = packet.toPublicKey, - contentLength = packet.content.length, - attachmentsCount = packet.attachments.size, - timestamp = packet.timestamp + messageId = packet.messageId, + fromPublicKey = packet.fromPublicKey, + toPublicKey = packet.toPublicKey, + contentLength = packet.content.length, + attachmentsCount = packet.attachments.size, + timestamp = packet.timestamp ) - + // 🔥 Проверяем, не заблокирован ли отправитель val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account) if (isBlocked) { MessageLogger.logBlockedSender(packet.fromPublicKey) return } - + // 🔥 Генерируем messageId если он пустой (как в Архиве - generateRandomKeyFormSeed) - val messageId = if (packet.messageId.isBlank()) { - val generatedId = generateMessageId(packet.fromPublicKey, packet.toPublicKey, packet.timestamp) - MessageLogger.debug("📥 Generated messageId: $generatedId (original was blank)") - generatedId - } else { - packet.messageId - } + val messageId = + if (packet.messageId.isBlank()) { + val generatedId = + generateMessageId( + packet.fromPublicKey, + packet.toPublicKey, + packet.timestamp + ) + MessageLogger.debug("📥 Generated messageId: $generatedId (original was blank)") + generatedId + } else { + packet.messageId + } // 🔥 ПЕРВЫЙ УРОВЕНЬ ЗАЩИТЫ: In-memory кэш (быстрая проверка без обращения к БД) // markAsProcessed возвращает false если сообщение уже обрабатывалось if (!markAsProcessed(messageId)) { - MessageLogger.debug("📥 SKIP (in-memory cache): Message $messageId already being processed") + MessageLogger.debug( + "📥 SKIP (in-memory cache): Message $messageId already being processed" + ) return } @@ -407,64 +403,69 @@ class MessageRepository private constructor(private val context: Context) { if (isDuplicate) { return } - + val dialogKey = getDialogKey(packet.fromPublicKey) - + try { // 🔑 КРИТИЧНО: Сохраняем ЗАШИФРОВАННЫЙ chacha_key (как в Desktop!) // Desktop: хранит зашифрованный ключ, расшифровывает только при использовании - // Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8') - + // Buffer.from(await decrypt(message.chacha_key, privatePlain), + // "binary").toString('utf-8') + // Расшифровываем - val plainText = MessageCrypto.decryptIncoming( - packet.content, - packet.chachaKey, - privateKey - ) - + val plainText = + MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey) + // 📝 LOG: Расшифровка успешна MessageLogger.logDecryptionSuccess( - messageId = messageId, - plainTextLength = plainText.length, - attachmentsCount = packet.attachments.size + messageId = messageId, + plainTextLength = plainText.length, + attachmentsCount = packet.attachments.size ) - + // Сериализуем attachments в JSON с расшифровкой MESSAGES blob - val attachmentsJson = serializeAttachmentsWithDecryption( - packet.attachments, - packet.chachaKey, - privateKey - ) - + val attachmentsJson = + serializeAttachmentsWithDecryption( + packet.attachments, + packet.chachaKey, + privateKey + ) + // 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop) processImageAttachments(packet.attachments, packet.chachaKey, privateKey) - + // 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя - processAvatarAttachments(packet.attachments, packet.fromPublicKey, packet.chachaKey, privateKey) - + processAvatarAttachments( + packet.attachments, + packet.fromPublicKey, + packet.chachaKey, + privateKey + ) + // 🔒 Шифруем plainMessage с использованием приватного ключа val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey) - + // Создаем entity для кэша и возможной вставки - val entity = MessageEntity( - account = account, - fromPublicKey = packet.fromPublicKey, - toPublicKey = packet.toPublicKey, - content = packet.content, - timestamp = packet.timestamp, - chachaKey = packet.chachaKey, - read = 0, - fromMe = 0, - delivered = DeliveryStatus.DELIVERED.value, - messageId = messageId, // 🔥 Используем сгенерированный messageId! - plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст - attachments = attachmentsJson, - dialogKey = dialogKey - ) - + val entity = + MessageEntity( + account = account, + fromPublicKey = packet.fromPublicKey, + toPublicKey = packet.toPublicKey, + content = packet.content, + timestamp = packet.timestamp, + chachaKey = packet.chachaKey, + read = 0, + fromMe = 0, + delivered = DeliveryStatus.DELIVERED.value, + messageId = messageId, // 🔥 Используем сгенерированный messageId! + plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст + attachments = attachmentsJson, + dialogKey = dialogKey + ) + // ✅ Проверяем существование перед вставкой (защита от дубликатов) val stillExists = messageDao.messageExists(account, messageId) - + if (!stillExists) { // Сохраняем в БД только если сообщения нет messageDao.insertMessage(entity) @@ -472,63 +473,60 @@ class MessageRepository private constructor(private val context: Context) { } else { MessageLogger.logDbSave(messageId, dialogKey, isNew = false) } - + // 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages dialogDao.updateDialogFromMessages(account, packet.fromPublicKey) - + // 🔥 Логируем что записалось в диалог val dialog = dialogDao.getDialog(account, packet.fromPublicKey) MessageLogger.logDialogUpdate( - dialogKey = dialogKey, - lastMessage = dialog?.lastMessage, - unreadCount = dialog?.unreadCount ?: 0 + dialogKey = dialogKey, + lastMessage = dialog?.lastMessage, + unreadCount = dialog?.unreadCount ?: 0 ) - + // 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа requestUserInfo(packet.fromPublicKey) - + // Обновляем кэш только если сообщение новое if (!stillExists) { val message = entity.toMessage() updateMessageCache(dialogKey, message) MessageLogger.logCacheUpdate(dialogKey, messageCache[dialogKey]?.value?.size ?: 0) - + // 🔔 Уведомляем UI о новом сообщении (emit dialogKey для обновления) _newMessageEvents.tryEmit(dialogKey) } - + // 📝 LOG: Успешная обработка MessageLogger.logReceiveSuccess(messageId, System.currentTimeMillis() - startTime) - } catch (e: Exception) { // 📝 LOG: Ошибка обработки MessageLogger.logDecryptionError(messageId, e) e.printStackTrace() } } - - /** - * Обработка подтверждения доставки - */ + + /** Обработка подтверждения доставки */ suspend fun handleDelivery(packet: PacketDelivery) { val account = currentAccount ?: return - + // 📝 LOG: Получено подтверждение доставки MessageLogger.logDeliveryStatus( - messageId = packet.messageId, - toPublicKey = packet.toPublicKey, - status = "DELIVERED" + messageId = packet.messageId, + toPublicKey = packet.toPublicKey, + status = "DELIVERED" ) - + messageDao.updateDeliveryStatus(account, packet.messageId, DeliveryStatus.DELIVERED.value) - + // Обновляем кэш val dialogKey = getDialogKey(packet.toPublicKey) updateMessageStatus(dialogKey, packet.messageId, DeliveryStatus.DELIVERED) // 🔔 Уведомляем UI о смене статуса доставки _deliveryStatusEvents.tryEmit( - DeliveryStatusUpdate(dialogKey, packet.messageId, DeliveryStatus.DELIVERED) + DeliveryStatusUpdate(dialogKey, packet.messageId, DeliveryStatus.DELIVERED) ) // 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageDelivered обновился @@ -536,64 +534,57 @@ class MessageRepository private constructor(private val context: Context) { } /** - * Обработка прочтения - * В Desktop PacketRead сообщает что собеседник прочитал наши сообщения + * Обработка прочтения В Desktop PacketRead сообщает что собеседник прочитал наши сообщения * fromPublicKey - кто прочитал (собеседник) */ suspend fun handleRead(packet: PacketRead) { val account = currentAccount ?: return - + MessageLogger.debug("👁 READ PACKET from: ${packet.fromPublicKey.take(16)}...") - + // Проверяем последнее сообщение ДО обновления val lastMsgBefore = messageDao.getLastMessageDebug(account, packet.fromPublicKey) - + // Отмечаем все наши исходящие сообщения к этому собеседнику как прочитанные messageDao.markAllAsRead(account, packet.fromPublicKey) - + // 🔥 DEBUG: Проверяем последнее сообщение ПОСЛЕ обновления val lastMsgAfter = messageDao.getLastMessageDebug(account, packet.fromPublicKey) - + // Обновляем кэш - все исходящие сообщения помечаем как прочитанные val dialogKey = getDialogKey(packet.fromPublicKey) val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0 messageCache[dialogKey]?.let { flow -> - flow.value = flow.value.map { msg -> - if (msg.isFromMe && !msg.isRead) msg.copy(isRead = true) - else msg - } + flow.value = + flow.value.map { msg -> + if (msg.isFromMe && !msg.isRead) msg.copy(isRead = true) else msg + } } // 🔔 Уведомляем UI о прочтении (пустой messageId = все исходящие сообщения) - _deliveryStatusEvents.tryEmit( - DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ) - ) + _deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ)) // 📝 LOG: Статус прочтения - MessageLogger.logReadStatus( - fromPublicKey = packet.fromPublicKey, - messagesCount = readCount - ) - + MessageLogger.logReadStatus(fromPublicKey = packet.fromPublicKey, messagesCount = readCount) + // 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageRead обновился dialogDao.updateDialogFromMessages(account, packet.fromPublicKey) - + // Логируем что записалось в диалог val dialog = dialogDao.getDialog(account, packet.fromPublicKey) } - + /** - * Отметить диалог как прочитанный - * 🔥 После обновления messages обновляем диалог через updateDialogFromMessages - * 📁 SAVED MESSAGES: Использует специальный метод для saved messages + * Отметить диалог как прочитанный 🔥 После обновления messages обновляем диалог через + * updateDialogFromMessages 📁 SAVED MESSAGES: Использует специальный метод для saved messages */ suspend fun markDialogAsRead(opponentKey: String) { val account = currentAccount ?: return val dialogKey = getDialogKey(opponentKey) - + // Отмечаем сообщения как прочитанные messageDao.markDialogAsRead(account, dialogKey) - + // 🔥 КРИТИЧНО: Пересчитываем счетчики из таблицы messages // чтобы unread_count обновился моментально // 📁 Используем специальный метод для saved messages @@ -602,64 +593,67 @@ class MessageRepository private constructor(private val context: Context) { } else { dialogDao.updateDialogFromMessages(account, opponentKey) } - } - - /** - * Отправить уведомление "печатает" - * 📁 Для Saved Messages - не отправляем - */ + + /** Отправить уведомление "печатает" 📁 Для Saved Messages - не отправляем */ fun sendTyping(toPublicKey: String) { val account = currentAccount ?: return val privateKey = currentPrivateKey ?: return - + // 📁 Для Saved Messages - не отправляем typing if (account == toPublicKey) { return } - + scope.launch { - val packet = PacketTyping().apply { - this.fromPublicKey = account - this.toPublicKey = toPublicKey - this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey) - } + val packet = + PacketTyping().apply { + this.fromPublicKey = account + this.toPublicKey = toPublicKey + this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey) + } ProtocolManager.send(packet) } } - - /** - * Создать или обновить диалог - */ + + /** Создать или обновить диалог */ suspend fun createOrUpdateDialog( - opponentKey: String, - title: String = "", - username: String = "", - verified: Boolean = false + opponentKey: String, + title: String = "", + username: String = "", + verified: Boolean = false ) { val account = currentAccount ?: return - + val existing = dialogDao.getDialog(account, opponentKey) if (existing != null) { - dialogDao.updateOpponentInfo(account, opponentKey, title, username, if (verified) 1 else 0) + dialogDao.updateOpponentInfo( + account, + opponentKey, + title, + username, + if (verified) 1 else 0 + ) } else { - dialogDao.insertDialog(DialogEntity( - account = account, - opponentKey = opponentKey, - opponentTitle = title, - opponentUsername = username, - verified = if (verified) 1 else 0 - )) + dialogDao.insertDialog( + DialogEntity( + account = account, + opponentKey = opponentKey, + opponentTitle = title, + opponentUsername = username, + verified = if (verified) 1 else 0 + ) + ) } } - + // =============================== // Private helpers // =============================== - + /** - * Получить ключ диалога для группировки сообщений - * 📁 SAVED MESSAGES: Для saved messages (account == opponentKey) возвращает просто account + * Получить ключ диалога для группировки сообщений 📁 SAVED MESSAGES: Для saved messages + * (account == opponentKey) возвращает просто account */ private fun getDialogKey(opponentKey: String): String { val account = currentAccount ?: return opponentKey @@ -668,10 +662,9 @@ class MessageRepository private constructor(private val context: Context) { return account } // Для обычных диалогов - сортируем ключи - return if (account < opponentKey) "$account:$opponentKey" - else "$opponentKey:$account" + return if (account < opponentKey) "$account:$opponentKey" else "$opponentKey:$account" } - + private fun updateMessageCache(dialogKey: String, message: Message) { messageCache[dialogKey]?.let { flow -> val currentList = flow.value.toMutableList() @@ -685,140 +678,138 @@ class MessageRepository private constructor(private val context: Context) { flow.value = currentList } } - + private fun updateMessageStatus(dialogKey: String, messageId: String, status: DeliveryStatus) { messageCache[dialogKey]?.let { flow -> - flow.value = flow.value.map { msg -> - if (msg.messageId == messageId) msg.copy(deliveryStatus = status) - else msg - } + flow.value = + flow.value.map { msg -> + if (msg.messageId == messageId) msg.copy(deliveryStatus = status) else msg + } } } - + private suspend fun updateDialog( - opponentKey: String, - lastMessage: String, - timestamp: Long, - incrementUnread: Boolean = false + opponentKey: String, + lastMessage: String, + timestamp: Long, + incrementUnread: Boolean = false ) { val account = currentAccount ?: return val privateKey = currentPrivateKey ?: return - + try { // 🔥 КРИТИЧНО: Сначала считаем реальное количество непрочитанных из messages val unreadCount = messageDao.getUnreadCountForDialog(account, opponentKey) - + // 🔒 Шифруем lastMessage val encryptedLastMessage = CryptoManager.encryptWithPassword(lastMessage, privateKey) - + // Проверяем существует ли диалог val existing = dialogDao.getDialog(account, opponentKey) - + if (existing != null) { // Обновляем существующий диалог dialogDao.updateLastMessage(account, opponentKey, encryptedLastMessage, timestamp) dialogDao.updateUnreadCount(account, opponentKey, unreadCount) } else { // Создаем новый диалог - dialogDao.insertDialog(DialogEntity( - account = account, - opponentKey = opponentKey, - lastMessage = encryptedLastMessage, - lastMessageTimestamp = timestamp, - unreadCount = unreadCount - )) + dialogDao.insertDialog( + DialogEntity( + account = account, + opponentKey = opponentKey, + lastMessage = encryptedLastMessage, + lastMessageTimestamp = timestamp, + unreadCount = unreadCount + ) + ) } - } catch (e: Exception) { e.printStackTrace() } } - - /** - * Обновить онлайн-статус пользователя в диалоге - */ + + /** Обновить онлайн-статус пользователя в диалоге */ suspend fun updateOnlineStatus(publicKey: String, isOnline: Boolean) { val account = currentAccount ?: return - + // Обновляем статус в базе dialogDao.updateOnlineStatus( - account = account, - opponentKey = publicKey, - isOnline = if (isOnline) 1 else 0, - lastSeen = if (!isOnline) System.currentTimeMillis() else 0 + account = account, + opponentKey = publicKey, + isOnline = if (isOnline) 1 else 0, + lastSeen = if (!isOnline) System.currentTimeMillis() else 0 ) - } - - /** - * Наблюдать за онлайн статусом пользователя - */ + + /** Наблюдать за онлайн статусом пользователя */ fun observeUserOnlineStatus(publicKey: String): Flow> { val account = currentAccount ?: return flowOf(false to 0L) - - return dialogDao.observeOnlineStatus(account, publicKey) - .map { info -> - if (info != null) { - (info.isOnline == 1) to info.lastSeen - } else { - false to 0L - } + + return dialogDao.observeOnlineStatus(account, publicKey).map { info -> + if (info != null) { + (info.isOnline == 1) to info.lastSeen + } else { + false to 0L } + } } - + /** - * Обновить информацию о пользователе в диалоге (имя, username, verified) - * Вызывается когда приходит ответ на PacketSearch + * Обновить информацию о пользователе в диалоге (имя, username, verified) Вызывается когда + * приходит ответ на PacketSearch */ - suspend fun updateDialogUserInfo(publicKey: String, title: String, username: String, verified: Int) { + suspend fun updateDialogUserInfo( + publicKey: String, + title: String, + username: String, + verified: Int + ) { val account = currentAccount ?: return - - + // Проверяем существует ли диалог с этим пользователем val existing = dialogDao.getDialog(account, publicKey) if (existing != null) { dialogDao.updateOpponentInfo(account, publicKey, title, username, verified) - + // 🔥 Проверим что данные сохранились val updated = dialogDao.getDialog(account, publicKey) - } else { - } + } else {} } - + /** - * Очистить кэш сообщений для конкретного диалога - * 🔥 ВАЖНО: Устанавливаем пустой список, а не просто удаляем - - * чтобы подписчики Flow увидели что диалог пуст + * Очистить кэш сообщений для конкретного диалога 🔥 ВАЖНО: Устанавливаем пустой список, а не + * просто удаляем - чтобы подписчики Flow увидели что диалог пуст */ fun clearDialogCache(opponentKey: String) { val dialogKey = getDialogKey(opponentKey) - + // Сначала устанавливаем пустой список чтобы все подписчики увидели messageCache[dialogKey]?.value = emptyList() - + // Затем удаляем из кэша messageCache.remove(dialogKey) } - + /** - * Запросить информацию о пользователе с сервера - * 🔥 Защита от бесконечных запросов - каждый ключ запрашивается только один раз + * Запросить информацию о пользователе с сервера 🔥 Защита от бесконечных запросов - каждый ключ + * запрашивается только один раз */ fun requestUserInfo(publicKey: String) { val privateKey = currentPrivateKey ?: return - + // 🔥 Не запрашиваем если уже запрашивали if (requestedUserInfoKeys.contains(publicKey)) { return } requestedUserInfoKeys.add(publicKey) - + scope.launch { val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) - val packet = PacketSearch().apply { - this.privateKey = privateKeyHash - this.search = publicKey - } + val packet = + PacketSearch().apply { + this.privateKey = privateKeyHash + this.search = publicKey + } ProtocolManager.send(packet) } } @@ -827,194 +818,194 @@ class MessageRepository private constructor(private val context: Context) { private fun MessageEntity.toMessage(): Message { // 🔓 Расшифровываем plainMessage с использованием приватного ключа val privateKey = currentPrivateKey - val decryptedText = if (privateKey != null && plainMessage.isNotEmpty()) { - try { - CryptoManager.decryptWithPassword(plainMessage, privateKey) ?: plainMessage - } catch (e: Exception) { - plainMessage // Fallback на зашифрованный текст если расшифровка не удалась - } - } else { - plainMessage - } - + val decryptedText = + if (privateKey != null && plainMessage.isNotEmpty()) { + try { + CryptoManager.decryptWithPassword(plainMessage, privateKey) ?: plainMessage + } catch (e: Exception) { + plainMessage // Fallback на зашифрованный текст если расшифровка не удалась + } + } else { + plainMessage + } + return Message( - id = id, - messageId = messageId, - fromPublicKey = fromPublicKey, - toPublicKey = toPublicKey, - content = decryptedText, // 🔓 Расшифрованный текст - timestamp = timestamp, - isFromMe = fromMe == 1, - isRead = read == 1, - deliveryStatus = DeliveryStatus.fromInt(delivered), - replyToMessageId = replyToMessageId + id = id, + messageId = messageId, + fromPublicKey = fromPublicKey, + toPublicKey = toPublicKey, + content = decryptedText, // 🔓 Расшифрованный текст + timestamp = timestamp, + isFromMe = fromMe == 1, + isRead = read == 1, + deliveryStatus = DeliveryStatus.fromInt(delivered), + replyToMessageId = replyToMessageId ) } - - private fun DialogEntity.toDialog() = Dialog( - opponentKey = opponentKey, - opponentTitle = opponentTitle, - opponentUsername = opponentUsername, - lastMessage = lastMessage, - lastMessageTimestamp = lastMessageTimestamp, - unreadCount = unreadCount, - isOnline = isOnline == 1, - lastSeen = lastSeen, - verified = verified == 1 - ) - + + private fun DialogEntity.toDialog() = + Dialog( + opponentKey = opponentKey, + opponentTitle = opponentTitle, + opponentUsername = opponentUsername, + lastMessage = lastMessage, + lastMessageTimestamp = lastMessageTimestamp, + unreadCount = unreadCount, + isOnline = isOnline == 1, + lastSeen = lastSeen, + verified = verified == 1 + ) + /** - * Сериализация attachments в JSON - * 🔥 ВАЖНО: blob НЕ сохраняется в БД (как в desktop) - * Только метаданные: id, type, preview, width, height - * blob скачивается с CDN по id при показе + * Сериализация attachments в JSON 🔥 ВАЖНО: blob НЕ сохраняется в БД (как в desktop) Только + * метаданные: id, type, preview, width, height blob скачивается с CDN по id при показе */ private fun serializeAttachments(attachments: List): String { if (attachments.isEmpty()) return "[]" - + val jsonArray = JSONArray() for (attachment in attachments) { - val jsonObj = JSONObject().apply { - put("id", attachment.id) - // 🔥 blob НЕ сохраняем в БД - скачивается с CDN по id - put("blob", "") - put("type", attachment.type.value) - put("preview", attachment.preview) - put("width", attachment.width) - put("height", attachment.height) - } + val jsonObj = + JSONObject().apply { + put("id", attachment.id) + // 🔥 blob НЕ сохраняем в БД - скачивается с CDN по id + put("blob", "") + put("type", attachment.type.value) + put("preview", attachment.preview) + put("width", attachment.width) + put("height", attachment.height) + } jsonArray.put(jsonObj) } return jsonArray.toString() } - + /** - * 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш - * Как в desktop: при получении attachment с типом AVATAR - сохраняем в avatar_cache + * 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш Как в desktop: при + * получении attachment с типом AVATAR - сохраняем в avatar_cache */ private suspend fun processAvatarAttachments( - attachments: List, - fromPublicKey: String, - encryptedKey: String, - privateKey: String + attachments: List, + fromPublicKey: String, + encryptedKey: String, + privateKey: String ) { - + for (attachment in attachments) { - + if (attachment.type == AttachmentType.AVATAR && attachment.blob.isNotEmpty()) { try { - + // 1. Расшифровываем blob с ChaCha ключом сообщения - val decryptedBlob = MessageCrypto.decryptAttachmentBlob( - attachment.blob, - encryptedKey, - privateKey - ) - - + val decryptedBlob = + MessageCrypto.decryptAttachmentBlob( + attachment.blob, + encryptedKey, + privateKey + ) + if (decryptedBlob != null) { // 2. Сохраняем аватар в кэш - val filePath = AvatarFileManager.saveAvatar(context, decryptedBlob, fromPublicKey) - - val entity = AvatarCacheEntity( - publicKey = fromPublicKey, - avatar = filePath, - timestamp = System.currentTimeMillis() - ) + val filePath = + AvatarFileManager.saveAvatar(context, decryptedBlob, fromPublicKey) + + val entity = + AvatarCacheEntity( + publicKey = fromPublicKey, + avatar = filePath, + timestamp = System.currentTimeMillis() + ) avatarDao.insertAvatar(entity) - + // 3. Очищаем старые аватары (оставляем последние 5) avatarDao.deleteOldAvatars(fromPublicKey, 5) - - } else { - } - } catch (e: Exception) { - } + } else {} + } catch (e: Exception) {} } } } - + /** - * 🖼️ Обработка IMAGE attachments - сохранение в файл (как в desktop) - * Desktop сохраняет: writeFile(`m/${md5(attachment.id + publicKey)}`, encryptedBlob) - * Файлы (FILE тип) НЕ сохраняются - они слишком большие, загружаются с CDN + * 🖼️ Обработка IMAGE attachments - сохранение в файл (как в desktop) Desktop сохраняет: + * writeFile(`m/${md5(attachment.id + publicKey)}`, encryptedBlob) Файлы (FILE тип) НЕ + * сохраняются - они слишком большие, загружаются с CDN */ private fun processImageAttachments( - attachments: List, - encryptedKey: String, - privateKey: String + attachments: List, + encryptedKey: String, + privateKey: String ) { val publicKey = currentAccount ?: return - + for (attachment in attachments) { // Сохраняем только IMAGE, не FILE (файлы загружаются с CDN при необходимости) if (attachment.type == AttachmentType.IMAGE && attachment.blob.isNotEmpty()) { try { - + // 1. Расшифровываем blob с ChaCha ключом сообщения - val decryptedBlob = MessageCrypto.decryptAttachmentBlob( - attachment.blob, - encryptedKey, - privateKey - ) - + val decryptedBlob = + MessageCrypto.decryptAttachmentBlob( + attachment.blob, + encryptedKey, + privateKey + ) + if (decryptedBlob != null) { // 2. Сохраняем в файл (как в desktop) - val saved = AttachmentFileManager.saveAttachment( - context = context, - blob = decryptedBlob, - attachmentId = attachment.id, - publicKey = publicKey, - privateKey = privateKey - ) - - if (saved) { - } else { - } - } else { - } - } catch (e: Exception) { - } + val saved = + AttachmentFileManager.saveAttachment( + context = context, + blob = decryptedBlob, + attachmentId = attachment.id, + publicKey = publicKey, + privateKey = privateKey + ) + + if (saved) {} else {} + } else {} + } catch (e: Exception) {} } } } - + /** - * Сериализация attachments в JSON с RE-ENCRYPTION для хранения в БД - * Для MESSAGES типа: + * Сериализация attachments в JSON с RE-ENCRYPTION для хранения в БД Для MESSAGES типа: * 1. Расшифровываем blob с ChaCha ключом сообщения * 2. Re-encrypt с приватным ключом (как в Desktop Архиве) * 3. Сохраняем зашифрованный blob в БД */ private fun serializeAttachmentsWithDecryption( - attachments: List, - encryptedKey: String, - privateKey: String + attachments: List, + encryptedKey: String, + privateKey: String ): String { if (attachments.isEmpty()) return "[]" - + val jsonArray = JSONArray() for (attachment in attachments) { val jsonObj = JSONObject() - + // ⚠️ НЕ сохраняем blob для IMAGE/FILE - слишком большие (SQLite CursorWindow 2MB limit) // Только MESSAGES (reply) сохраняем - они небольшие if (attachment.type == AttachmentType.MESSAGES && attachment.blob.isNotEmpty()) { try { // 1. Расшифровываем с ChaCha ключом сообщения - val decryptedBlob = MessageCrypto.decryptAttachmentBlob( - attachment.blob, - encryptedKey, - privateKey - ) - + val decryptedBlob = + MessageCrypto.decryptAttachmentBlob( + attachment.blob, + encryptedKey, + privateKey + ) + if (decryptedBlob != null) { // 2. Re-encrypt с приватным ключом для хранения (как в Desktop Архиве) - val reEncryptedBlob = CryptoManager.encryptWithPassword(decryptedBlob, privateKey) - - // 3. Сохраняем ЗАШИФРОВАННЫЙ blob в БД (только для MESSAGES - они небольшие) + val reEncryptedBlob = + CryptoManager.encryptWithPassword(decryptedBlob, privateKey) + + // 3. Сохраняем ЗАШИФРОВАННЫЙ blob в БД (только для MESSAGES - они + // небольшие) jsonObj.put("id", attachment.id) - jsonObj.put("blob", reEncryptedBlob) // 🔒 Зашифрован приватным ключом! + jsonObj.put("blob", reEncryptedBlob) // 🔒 Зашифрован приватным ключом! jsonObj.put("type", attachment.type.value) jsonObj.put("preview", attachment.preview) jsonObj.put("width", attachment.width) @@ -1040,13 +1031,13 @@ class MessageRepository private constructor(private val context: Context) { } else { // Для IMAGE/FILE - НЕ сохраняем blob (пустой) jsonObj.put("id", attachment.id) - jsonObj.put("blob", "") // Пустой blob для IMAGE/FILE + jsonObj.put("blob", "") // Пустой blob для IMAGE/FILE jsonObj.put("type", attachment.type.value) jsonObj.put("preview", attachment.preview) jsonObj.put("width", attachment.width) jsonObj.put("height", attachment.height) } - + jsonArray.put(jsonObj) } return jsonArray.toString() diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index d5d4587..5938b78 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -3,271 +3,232 @@ package com.rosetta.messenger.database import androidx.room.* import kotlinx.coroutines.flow.Flow -/** - * 🔥 Data class для статуса последнего сообщения - */ +/** 🔥 Data class для статуса последнего сообщения */ data class LastMessageStatus( - @ColumnInfo(name = "from_me") val fromMe: Int, - @ColumnInfo(name = "delivered") val delivered: Int, - @ColumnInfo(name = "read") val read: Int + @ColumnInfo(name = "from_me") val fromMe: Int, + @ColumnInfo(name = "delivered") val delivered: Int, + @ColumnInfo(name = "read") val read: Int ) -/** - * Entity для сообщений - как в React Native версии - */ +/** Entity для сообщений - как в React Native версии */ @Entity( - tableName = "messages", - indices = [ - Index(value = ["account", "from_public_key", "to_public_key", "timestamp"]), - Index(value = ["account", "message_id"], unique = true), - Index(value = ["account", "dialog_key", "timestamp"]) - ] + tableName = "messages", + indices = + [ + Index(value = ["account", "from_public_key", "to_public_key", "timestamp"]), + Index(value = ["account", "message_id"], unique = true), + Index(value = ["account", "dialog_key", "timestamp"])] ) data class MessageEntity( - @PrimaryKey(autoGenerate = true) - val id: Long = 0, - - @ColumnInfo(name = "account") - val account: String, // Мой публичный ключ - - @ColumnInfo(name = "from_public_key") - val fromPublicKey: String, // Отправитель - - @ColumnInfo(name = "to_public_key") - val toPublicKey: String, // Получатель - - @ColumnInfo(name = "content") - val content: String, // Зашифрованное содержимое - - @ColumnInfo(name = "timestamp") - val timestamp: Long, // Unix timestamp - - @ColumnInfo(name = "chacha_key") - val chachaKey: String, // Зашифрованный ключ - - @ColumnInfo(name = "read") - val read: Int = 0, // Прочитано (0/1) - - @ColumnInfo(name = "from_me") - val fromMe: Int = 0, // Мое сообщение (0/1) - - @ColumnInfo(name = "delivered") - val delivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR) - - @ColumnInfo(name = "message_id") - val messageId: String, // UUID сообщения - - @ColumnInfo(name = "plain_message") - val plainMessage: String, // 🔒 Зашифрованный текст (encryptWithPassword) для хранения в БД - - @ColumnInfo(name = "attachments") - val attachments: String = "[]", // JSON массив вложений - - @ColumnInfo(name = "reply_to_message_id") - val replyToMessageId: String? = null, // ID цитируемого сообщения - - @ColumnInfo(name = "dialog_key") - val dialogKey: String // Ключ диалога для быстрой выборки + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(name = "account") val account: String, // Мой публичный ключ + @ColumnInfo(name = "from_public_key") val fromPublicKey: String, // Отправитель + @ColumnInfo(name = "to_public_key") val toPublicKey: String, // Получатель + @ColumnInfo(name = "content") val content: String, // Зашифрованное содержимое + @ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp + @ColumnInfo(name = "chacha_key") val chachaKey: String, // Зашифрованный ключ + @ColumnInfo(name = "read") val read: Int = 0, // Прочитано (0/1) + @ColumnInfo(name = "from_me") val fromMe: Int = 0, // Мое сообщение (0/1) + @ColumnInfo(name = "delivered") + val delivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR) + @ColumnInfo(name = "message_id") val messageId: String, // UUID сообщения + @ColumnInfo(name = "plain_message") + val plainMessage: String, // 🔒 Зашифрованный текст (encryptWithPassword) для хранения в БД + @ColumnInfo(name = "attachments") val attachments: String = "[]", // JSON массив вложений + @ColumnInfo(name = "reply_to_message_id") + val replyToMessageId: String? = null, // ID цитируемого сообщения + @ColumnInfo(name = "dialog_key") val dialogKey: String // Ключ диалога для быстрой выборки ) -/** - * Entity для диалогов (кэш последнего сообщения) - */ +/** Entity для диалогов (кэш последнего сообщения) */ @Entity( - tableName = "dialogs", - indices = [ - Index(value = ["account", "opponent_key"], unique = true), - Index(value = ["account", "last_message_timestamp"]) - ] + tableName = "dialogs", + indices = + [ + Index(value = ["account", "opponent_key"], unique = true), + Index(value = ["account", "last_message_timestamp"])] ) data class DialogEntity( - @PrimaryKey(autoGenerate = true) - val id: Long = 0, - - @ColumnInfo(name = "account") - val account: String, // Мой публичный ключ - - @ColumnInfo(name = "opponent_key") - val opponentKey: String, // Публичный ключ собеседника - - @ColumnInfo(name = "opponent_title") - val opponentTitle: String = "", // Имя собеседника - - @ColumnInfo(name = "opponent_username") - val opponentUsername: String = "", // Username собеседника - - @ColumnInfo(name = "last_message") - val lastMessage: String = "", // 🔒 Последнее сообщение (зашифрованное для превью) - - @ColumnInfo(name = "last_message_timestamp") - val lastMessageTimestamp: Long = 0, // Timestamp последнего сообщения - - @ColumnInfo(name = "unread_count") - val unreadCount: Int = 0, // Количество непрочитанных - - @ColumnInfo(name = "is_online") - val isOnline: Int = 0, // Онлайн статус - - @ColumnInfo(name = "last_seen") - val lastSeen: Long = 0, // Последний раз онлайн - - @ColumnInfo(name = "verified") - val verified: Int = 0, // Верифицирован - - @ColumnInfo(name = "i_have_sent", defaultValue = "0") - val iHaveSent: Int = 0, // Отправлял ли я сообщения в этот диалог (0/1) - - @ColumnInfo(name = "last_message_from_me", defaultValue = "0") - val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1) - - @ColumnInfo(name = "last_message_delivered", defaultValue = "0") - val lastMessageDelivered: Int = 0, // Статус доставки последнего сообщения (0=WAITING, 1=DELIVERED, 2=ERROR) - - @ColumnInfo(name = "last_message_read", defaultValue = "0") - val lastMessageRead: Int = 0 // Прочитано последнее сообщение (0/1) + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(name = "account") val account: String, // Мой публичный ключ + @ColumnInfo(name = "opponent_key") val opponentKey: String, // Публичный ключ собеседника + @ColumnInfo(name = "opponent_title") val opponentTitle: String = "", // Имя собеседника + @ColumnInfo(name = "opponent_username") + val opponentUsername: String = "", // Username собеседника + @ColumnInfo(name = "last_message") + val lastMessage: String = "", // 🔒 Последнее сообщение (зашифрованное для превью) + @ColumnInfo(name = "last_message_timestamp") + val lastMessageTimestamp: Long = 0, // Timestamp последнего сообщения + @ColumnInfo(name = "unread_count") val unreadCount: Int = 0, // Количество непрочитанных + @ColumnInfo(name = "is_online") val isOnline: Int = 0, // Онлайн статус + @ColumnInfo(name = "last_seen") val lastSeen: Long = 0, // Последний раз онлайн + @ColumnInfo(name = "verified") val verified: Int = 0, // Верифицирован + @ColumnInfo(name = "i_have_sent", defaultValue = "0") + val iHaveSent: Int = 0, // Отправлял ли я сообщения в этот диалог (0/1) + @ColumnInfo(name = "last_message_from_me", defaultValue = "0") + val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1) + @ColumnInfo(name = "last_message_delivered", defaultValue = "0") + val lastMessageDelivered: Int = + 0, // Статус доставки последнего сообщения (0=WAITING, 1=DELIVERED, 2=ERROR) + @ColumnInfo(name = "last_message_read", defaultValue = "0") + val lastMessageRead: Int = 0, // Прочитано последнее сообщение (0/1) + @ColumnInfo(name = "last_message_attachments", defaultValue = "[]") + val lastMessageAttachments: String = + "[]" // 📎 JSON attachments последнего сообщения (кэш из messages) ) -/** - * DAO для работы с сообщениями - */ +/** DAO для работы с сообщениями */ @Dao interface MessageDao { - - /** - * Вставка нового сообщения (IGNORE если уже существует) - */ + + /** Вставка нового сообщения (IGNORE если уже существует) */ @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertMessage(message: MessageEntity): Long - - /** - * Вставка нескольких сообщений (IGNORE если уже существуют) - */ + + /** Вставка нескольких сообщений (IGNORE если уже существуют) */ @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertMessages(messages: List) - - /** - * Получить сообщения диалога (постранично) - */ - @Query(""" + + /** Получить сообщения диалога (постранично) */ + @Query( + """ SELECT * FROM messages WHERE account = :account AND dialog_key = :dialogKey ORDER BY timestamp DESC LIMIT :limit OFFSET :offset - """) - suspend fun getMessages(account: String, dialogKey: String, limit: Int, offset: Int): List - + """ + ) + suspend fun getMessages( + account: String, + dialogKey: String, + limit: Int, + offset: Int + ): List + /** - * 📁 Получить сообщения для Saved Messages (постранично) - * Специальный метод для случая когда from_public_key = to_public_key = account - * Использует упрощенный запрос без дублирования OR условий + * 📁 Получить сообщения для Saved Messages (постранично) Специальный метод для случая когда + * from_public_key = to_public_key = account Использует упрощенный запрос без дублирования OR + * условий */ - @Query(""" + @Query( + """ SELECT * FROM messages WHERE account = :account AND from_public_key = :account AND to_public_key = :account ORDER BY timestamp DESC LIMIT :limit OFFSET :offset - """) - suspend fun getMessagesForSavedDialog(account: String, limit: Int, offset: Int): List - - /** - * 📁 Получить количество сообщений в Saved Messages - */ - @Query(""" + """ + ) + suspend fun getMessagesForSavedDialog( + account: String, + limit: Int, + offset: Int + ): List + + /** 📁 Получить количество сообщений в Saved Messages */ + @Query( + """ SELECT COUNT(*) FROM messages WHERE account = :account AND from_public_key = :account AND to_public_key = :account - """) + """ + ) suspend fun getMessageCountForSavedDialog(account: String): Int - - /** - * Получить сообщения диалога как Flow - */ - @Query(""" + + /** Получить сообщения диалога как Flow */ + @Query( + """ SELECT * FROM messages WHERE account = :account AND dialog_key = :dialogKey ORDER BY timestamp ASC - """) + """ + ) fun getMessagesFlow(account: String, dialogKey: String): Flow> - - /** - * Получить количество сообщений в диалоге - */ - @Query(""" + + /** Получить количество сообщений в диалоге */ + @Query( + """ SELECT COUNT(*) FROM messages WHERE account = :account AND dialog_key = :dialogKey - """) + """ + ) suspend fun getMessageCount(account: String, dialogKey: String): Int - + /** - * 📸 Получить количество сообщений между двумя пользователями - * (для проверки первого сообщения при отправке аватара) + * 📸 Получить количество сообщений между двумя пользователями (для проверки первого сообщения + * при отправке аватара) */ - @Query(""" + @Query( + """ SELECT COUNT(*) FROM messages WHERE account = :account AND ((from_public_key = :sender AND to_public_key = :recipient) OR (from_public_key = :recipient AND to_public_key = :sender)) - """) + """ + ) suspend fun getMessageCount(account: String, sender: String, recipient: String): Int - - /** - * Получить последние N сообщений диалога - */ - @Query(""" + + /** Получить последние N сообщений диалога */ + @Query( + """ SELECT * FROM messages WHERE account = :account AND dialog_key = :dialogKey ORDER BY timestamp DESC LIMIT :limit - """) - suspend fun getRecentMessages(account: String, dialogKey: String, limit: Int): List - - /** - * Найти сообщение по ID - */ + """ + ) + suspend fun getRecentMessages( + account: String, + dialogKey: String, + limit: Int + ): List + + /** Найти сообщение по ID */ @Query("SELECT * FROM messages WHERE account = :account AND message_id = :messageId LIMIT 1") suspend fun getMessageById(account: String, messageId: String): MessageEntity? - - /** - * Обновить статус доставки - */ - @Query("UPDATE messages SET delivered = :status WHERE account = :account AND message_id = :messageId") + + /** Обновить статус доставки */ + @Query( + "UPDATE messages SET delivered = :status WHERE account = :account AND message_id = :messageId" + ) suspend fun updateDeliveryStatus(account: String, messageId: String, status: Int) - - /** - * 🔄 Обновить статус доставки и attachments (для очистки localUri после отправки) - */ - @Query("UPDATE messages SET delivered = :status, attachments = :attachments WHERE account = :account AND message_id = :messageId") - suspend fun updateDeliveryStatusAndAttachments(account: String, messageId: String, status: Int, attachments: String) - - /** - * Обновить статус прочтения - */ + + /** 🔄 Обновить статус доставки и attachments (для очистки localUri после отправки) */ + @Query( + "UPDATE messages SET delivered = :status, attachments = :attachments WHERE account = :account AND message_id = :messageId" + ) + suspend fun updateDeliveryStatusAndAttachments( + account: String, + messageId: String, + status: Int, + attachments: String + ) + + /** Обновить статус прочтения */ @Query("UPDATE messages SET read = 1 WHERE account = :account AND message_id = :messageId") suspend fun markAsRead(account: String, messageId: String) - - /** - * Отметить все сообщения диалога как прочитанные - */ - @Query(""" + + /** Отметить все сообщения диалога как прочитанные */ + @Query( + """ UPDATE messages SET read = 1 WHERE account = :account AND dialog_key = :dialogKey AND from_me = 0 - """) + """ + ) suspend fun markDialogAsRead(account: String, dialogKey: String) - - /** - * Удалить сообщение - */ + + /** Удалить сообщение */ @Query("DELETE FROM messages WHERE account = :account AND message_id = :messageId") suspend fun deleteMessage(account: String, messageId: String) - + /** - * Найти сообщение по публичному ключу отправителя и timestamp (для reply) - * Ищет с допуском по времени для учета возможных рассинхронизаций + * Найти сообщение по публичному ключу отправителя и timestamp (для reply) Ищет с допуском по + * времени для учета возможных рассинхронизаций */ - @Query(""" + @Query( + """ SELECT * FROM messages WHERE account = :account AND dialog_key = :dialogKey @@ -275,464 +236,395 @@ interface MessageDao { AND timestamp BETWEEN :timestampFrom AND :timestampTo ORDER BY timestamp ASC LIMIT 1 - """) + """ + ) suspend fun findMessageByContent( - account: String, - dialogKey: String, - fromPublicKey: String, - timestampFrom: Long, - timestampTo: Long + account: String, + dialogKey: String, + fromPublicKey: String, + timestampFrom: Long, + timestampTo: Long ): MessageEntity? - + /** - * Получить количество непрочитанных сообщений для диалога - * Считает только входящие сообщения (from_me = 0) которые не прочитаны (read = 0) + * Получить количество непрочитанных сообщений для диалога Считает только входящие сообщения + * (from_me = 0) которые не прочитаны (read = 0) */ - @Query(""" + @Query( + """ SELECT COUNT(*) FROM messages WHERE account = :account AND from_public_key = :opponentKey AND from_me = 0 AND read = 0 - """) + """ + ) suspend fun getUnreadCountForDialog(account: String, opponentKey: String): Int - - /** - * Удалить все сообщения диалога (возвращает количество удалённых) - */ + + /** Удалить все сообщения диалога (возвращает количество удалённых) */ @Query("DELETE FROM messages WHERE account = :account AND dialog_key = :dialogKey") suspend fun deleteDialog(account: String, dialogKey: String): Int - - /** - * Удалить все сообщения между двумя пользователями (возвращает количество удалённых) - */ - @Query(""" + + /** Удалить все сообщения между двумя пользователями (возвращает количество удалённых) */ + @Query( + """ DELETE FROM messages WHERE account = :account AND ( (from_public_key = :user1 AND to_public_key = :user2) OR (from_public_key = :user2 AND to_public_key = :user1) ) - """) + """ + ) suspend fun deleteMessagesBetweenUsers(account: String, user1: String, user2: String): Int - - /** - * Количество непрочитанных сообщений в диалоге - */ - @Query(""" + + /** Количество непрочитанных сообщений в диалоге */ + @Query( + """ SELECT COUNT(*) FROM messages WHERE account = :account AND dialog_key = :dialogKey AND from_me = 0 AND read = 0 - """) + """ + ) suspend fun getUnreadCount(account: String, dialogKey: String): Int - - /** - * Проверить существование сообщения - */ - @Query("SELECT EXISTS(SELECT 1 FROM messages WHERE account = :account AND message_id = :messageId)") + + /** Проверить существование сообщения */ + @Query( + "SELECT EXISTS(SELECT 1 FROM messages WHERE account = :account AND message_id = :messageId)" + ) suspend fun messageExists(account: String, messageId: String): Boolean - + /** - * Отметить все исходящие сообщения к собеседнику как прочитанные - * Используется когда приходит PacketRead от собеседника - * 🔥 ВАЖНО: delivered=3 означает READ (синхронизировано с ChatViewModel) + * Отметить все исходящие сообщения к собеседнику как прочитанные Используется когда приходит + * PacketRead от собеседника 🔥 ВАЖНО: delivered=3 означает READ (синхронизировано с + * ChatViewModel) */ - @Query(""" + @Query( + """ UPDATE messages SET delivered = 3, read = 1 WHERE account = :account AND to_public_key = :opponent AND from_me = 1 - """) + """ + ) suspend fun markAllAsRead(account: String, opponent: String) - - /** - * 🔥 DEBUG: Получить последнее сообщение в диалоге для отладки - */ - @Query(""" + + /** 🔥 DEBUG: Получить последнее сообщение в диалоге для отладки */ + @Query( + """ SELECT * FROM messages WHERE account = :account AND ((from_public_key = :opponent AND to_public_key = :account) OR (from_public_key = :account AND to_public_key = :opponent)) ORDER BY timestamp DESC, id DESC LIMIT 1 - """) + """ + ) suspend fun getLastMessageDebug(account: String, opponent: String): MessageEntity? - + /** - * 🔥 Получить статус последнего сообщения (fromMe, delivered, read) для диалога - * Возвращает null если сообщений нет + * 🔥 Получить статус последнего сообщения (fromMe, delivered, read) для диалога Возвращает null + * если сообщений нет */ - @Query(""" + @Query( + """ SELECT from_me, delivered, read FROM messages WHERE account = :account AND ((from_public_key = :opponent AND to_public_key = :account) OR (from_public_key = :account AND to_public_key = :opponent)) ORDER BY timestamp DESC, id DESC LIMIT 1 - """) + """ + ) suspend fun getLastMessageStatus(account: String, opponent: String): LastMessageStatus? - + /** - * 📎 Получить attachments последнего сообщения для диалога - * Возвращает null если сообщений нет или нет attachments + * 📎 Получить attachments последнего сообщения для диалога Возвращает null если сообщений нет + * или нет attachments */ - @Query(""" + @Query( + """ SELECT attachments FROM messages WHERE account = :account AND ((from_public_key = :opponent AND to_public_key = :account) OR (from_public_key = :account AND to_public_key = :opponent)) ORDER BY timestamp DESC, id DESC LIMIT 1 - """) + """ + ) suspend fun getLastMessageAttachments(account: String, opponent: String): String? } -/** - * DAO для работы с диалогами - */ +/** DAO для работы с диалогами */ @Dao interface DialogDao { - - /** - * Вставка/обновление диалога - */ + + /** Вставка/обновление диалога */ @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertDialog(dialog: DialogEntity): Long - + /** - * Получить все диалоги отсортированные по последнему сообщению - * Исключает requests (диалоги без исходящих сообщений от нас) - * Исключает пустые диалоги (без сообщений) + * Получить все диалоги отсортированные по последнему сообщению Исключает requests (диалоги без + * исходящих сообщений от нас) Исключает пустые диалоги (без сообщений) */ - @Query(""" + @Query( + """ SELECT * FROM dialogs WHERE account = :account AND i_have_sent = 1 AND last_message_timestamp > 0 ORDER BY last_message_timestamp DESC - """) + """ + ) fun getDialogsFlow(account: String): Flow> - + /** - * Получить requests - диалоги где нам писали, но мы не отвечали - * Исключает пустые диалоги (без сообщений) + * Получить requests - диалоги где нам писали, но мы не отвечали Исключает пустые диалоги (без + * сообщений) */ - @Query(""" + @Query( + """ SELECT * FROM dialogs WHERE account = :account AND i_have_sent = 0 AND last_message_timestamp > 0 ORDER BY last_message_timestamp DESC - """) + """ + ) fun getRequestsFlow(account: String): Flow> - - /** - * Получить количество requests - * Исключает пустые диалоги (без сообщений) - */ - @Query(""" + + /** Получить количество requests Исключает пустые диалоги (без сообщений) */ + @Query( + """ SELECT COUNT(*) FROM dialogs WHERE account = :account AND i_have_sent = 0 AND last_message_timestamp > 0 - """) + """ + ) fun getRequestsCountFlow(account: String): Flow - - /** - * Получить диалог - */ + + /** Получить диалог */ @Query("SELECT * FROM dialogs WHERE account = :account AND opponent_key = :opponentKey LIMIT 1") suspend fun getDialog(account: String, opponentKey: String): DialogEntity? - - /** - * Обновить последнее сообщение - */ - @Query(""" + + /** Обновить последнее сообщение */ + @Query( + """ UPDATE dialogs SET last_message = :lastMessage, last_message_timestamp = :timestamp WHERE account = :account AND opponent_key = :opponentKey - """) - suspend fun updateLastMessage(account: String, opponentKey: String, lastMessage: String, timestamp: Long) - - /** - * Обновить количество непрочитанных - */ - @Query("UPDATE dialogs SET unread_count = :count WHERE account = :account AND opponent_key = :opponentKey") + """ + ) + suspend fun updateLastMessage( + account: String, + opponentKey: String, + lastMessage: String, + timestamp: Long + ) + + /** Обновить количество непрочитанных */ + @Query( + "UPDATE dialogs SET unread_count = :count WHERE account = :account AND opponent_key = :opponentKey" + ) suspend fun updateUnreadCount(account: String, opponentKey: String, count: Int) - - /** - * Инкрементировать непрочитанные - */ - @Query("UPDATE dialogs SET unread_count = unread_count + 1 WHERE account = :account AND opponent_key = :opponentKey") + + /** Инкрементировать непрочитанные */ + @Query( + "UPDATE dialogs SET unread_count = unread_count + 1 WHERE account = :account AND opponent_key = :opponentKey" + ) suspend fun incrementUnreadCount(account: String, opponentKey: String) - - /** - * Сбросить непрочитанные - */ - @Query("UPDATE dialogs SET unread_count = 0 WHERE account = :account AND opponent_key = :opponentKey") + + /** Сбросить непрочитанные */ + @Query( + "UPDATE dialogs SET unread_count = 0 WHERE account = :account AND opponent_key = :opponentKey" + ) suspend fun clearUnreadCount(account: String, opponentKey: String) - - /** - * Отметить что я отправлял сообщения в этот диалог - * Возвращает количество обновлённых строк - */ - @Query("UPDATE dialogs SET i_have_sent = 1 WHERE account = :account AND opponent_key = :opponentKey") + + /** Отметить что я отправлял сообщения в этот диалог Возвращает количество обновлённых строк */ + @Query( + "UPDATE dialogs SET i_have_sent = 1 WHERE account = :account AND opponent_key = :opponentKey" + ) suspend fun markIHaveSent(account: String, opponentKey: String): Int - - /** - * Обновить онлайн статус - */ - @Query(""" + + /** Обновить онлайн статус */ + @Query( + """ UPDATE dialogs SET is_online = :isOnline, last_seen = :lastSeen WHERE account = :account AND opponent_key = :opponentKey - """) - suspend fun updateOnlineStatus(account: String, opponentKey: String, isOnline: Int, lastSeen: Long) - - /** - * Получить онлайн статус пользователя - */ - @Query(""" + """ + ) + suspend fun updateOnlineStatus( + account: String, + opponentKey: String, + isOnline: Int, + lastSeen: Long + ) + + /** Получить онлайн статус пользователя */ + @Query( + """ SELECT is_online, last_seen FROM dialogs WHERE account = :account AND opponent_key = :opponentKey LIMIT 1 - """) - fun observeOnlineStatus(account: String, opponentKey: String): Flow - - data class OnlineStatusInfo( - @ColumnInfo(name = "is_online") val isOnline: Int, - @ColumnInfo(name = "last_seen") val lastSeen: Long + """ ) - - /** - * Удалить диалог - */ + fun observeOnlineStatus(account: String, opponentKey: String): Flow + + data class OnlineStatusInfo( + @ColumnInfo(name = "is_online") val isOnline: Int, + @ColumnInfo(name = "last_seen") val lastSeen: Long + ) + + /** Удалить диалог */ @Query("DELETE FROM dialogs WHERE account = :account AND opponent_key = :opponentKey") suspend fun deleteDialog(account: String, opponentKey: String) - - /** - * Обновить информацию о собеседнике - */ - @Query(""" + + /** Обновить информацию о собеседнике */ + @Query( + """ UPDATE dialogs SET opponent_title = :title, opponent_username = :username, verified = :verified WHERE account = :account AND opponent_key = :opponentKey - """) - suspend fun updateOpponentInfo( - account: String, - opponentKey: String, - title: String, - username: String, - verified: Int + """ ) - + suspend fun updateOpponentInfo( + account: String, + opponentKey: String, + title: String, + username: String, + verified: Int + ) + /** - * Получить общее количество непрочитанных сообщений, исключая указанный диалог - * Используется для отображения badge на кнопке "назад" в экране чата + * Получить общее количество непрочитанных сообщений, исключая указанный диалог Используется для + * отображения badge на кнопке "назад" в экране чата */ - @Query(""" + @Query( + """ SELECT COALESCE(SUM(unread_count), 0) FROM dialogs WHERE account = :account AND opponent_key != :excludeOpponentKey - """) + """ + ) fun getTotalUnreadCountExcludingFlow(account: String, excludeOpponentKey: String): Flow - - /** - * Обновить диалог, пересчитав счетчики из таблицы messages - * Этот метод аналогичен updateDialog из Архива - обновляет все поля диалога одним запросом - * - * Логика: - * 1. Берем последнее сообщение (по timestamp DESC) - * 2. Считаем количество непрочитанных сообщений (from_me = 0 AND read = 0) - * 3. Вычисляем i_have_sent = 1 если есть исходящие сообщения (from_me = 1) - как sended в Архиве - * 4. Обновляем диалог или создаем новый ТОЛЬКО если есть сообщения! - */ - @Query(""" - INSERT OR REPLACE INTO dialogs ( - account, - opponent_key, - opponent_title, - opponent_username, - last_message, - last_message_timestamp, - unread_count, - is_online, - last_seen, - verified, - i_have_sent, - last_message_from_me, - last_message_delivered, - last_message_read - ) - SELECT - :account AS account, - :opponentKey AS opponent_key, - COALESCE( - (SELECT opponent_title FROM dialogs WHERE account = :account AND opponent_key = :opponentKey), - '' - ) AS opponent_title, - COALESCE( - (SELECT opponent_username FROM dialogs WHERE account = :account AND opponent_key = :opponentKey), - '' - ) AS opponent_username, - COALESCE( - (SELECT plain_message FROM messages - WHERE account = :account - AND ((from_public_key = :opponentKey AND to_public_key = :account) - OR (from_public_key = :account AND to_public_key = :opponentKey)) - ORDER BY timestamp DESC LIMIT 1), - '' - ) AS last_message, - COALESCE( - (SELECT MAX(timestamp) FROM messages - WHERE account = :account - AND ((from_public_key = :opponentKey AND to_public_key = :account) - OR (from_public_key = :account AND to_public_key = :opponentKey))), - 0 - ) AS last_message_timestamp, - COALESCE( - (SELECT COUNT(*) FROM messages - WHERE account = :account - AND from_public_key = :opponentKey - AND to_public_key = :account - AND from_me = 0 - AND read = 0), - 0 - ) AS unread_count, - COALESCE( - (SELECT is_online FROM dialogs WHERE account = :account AND opponent_key = :opponentKey), - 0 - ) AS is_online, - COALESCE( - (SELECT last_seen FROM dialogs WHERE account = :account AND opponent_key = :opponentKey), - 0 - ) AS last_seen, - COALESCE( - (SELECT verified FROM dialogs WHERE account = :account AND opponent_key = :opponentKey), - 0 - ) AS verified, - CASE - WHEN (SELECT COUNT(*) FROM messages - WHERE account = :account - AND from_public_key = :account - AND to_public_key = :opponentKey - AND from_me = 1) > 0 - THEN 1 - ELSE COALESCE( - (SELECT i_have_sent FROM dialogs WHERE account = :account AND opponent_key = :opponentKey), - 0 - ) - END AS i_have_sent, - COALESCE( - (SELECT from_me FROM messages - WHERE account = :account - AND ((from_public_key = :opponentKey AND to_public_key = :account) - OR (from_public_key = :account AND to_public_key = :opponentKey)) - ORDER BY timestamp DESC, id DESC LIMIT 1), - 0 - ) AS last_message_from_me, - COALESCE( - (SELECT - CASE WHEN from_me = 1 THEN delivered ELSE 0 END - FROM messages - WHERE account = :account - AND ((from_public_key = :opponentKey AND to_public_key = :account) - OR (from_public_key = :account AND to_public_key = :opponentKey)) - ORDER BY timestamp DESC, id DESC LIMIT 1), - 0 - ) AS last_message_delivered, - COALESCE( - (SELECT - CASE WHEN from_me = 1 THEN read ELSE 0 END - FROM messages - WHERE account = :account - AND ((from_public_key = :opponentKey AND to_public_key = :account) - OR (from_public_key = :account AND to_public_key = :opponentKey)) - ORDER BY timestamp DESC, id DESC LIMIT 1), - 0 - ) AS last_message_read - WHERE EXISTS ( - SELECT 1 FROM messages - WHERE account = :account - AND ((from_public_key = :opponentKey AND to_public_key = :account) - OR (from_public_key = :account AND to_public_key = :opponentKey)) - ) - """) - suspend fun updateDialogFromMessages(account: String, opponentKey: String) - - /** - * 📁 Обновить Saved Messages диалог, пересчитав счетчики из таблицы messages - * Специальный метод для случая когда opponentKey == account (saved messages) - * Использует упрощенный запрос без дублирования OR условий - * - * Ключевые отличия от обычного updateDialogFromMessages: - * 1. Упрощенные WHERE условия: from_public_key = :account AND to_public_key = :account - * 2. unread_count всегда 0 (нельзя иметь непрочитанные от самого себя) - * 3. i_have_sent всегда 1 (все сообщения исходящие) - */ - @Query(""" - INSERT OR REPLACE INTO dialogs ( - account, - opponent_key, - opponent_title, - opponent_username, - last_message, - last_message_timestamp, - unread_count, - is_online, - last_seen, - verified, - i_have_sent, - last_message_from_me, - last_message_delivered, - last_message_read - ) - SELECT - :account AS account, - :account AS opponent_key, - COALESCE( - (SELECT opponent_title FROM dialogs WHERE account = :account AND opponent_key = :account), - '' - ) AS opponent_title, - COALESCE( - (SELECT opponent_username FROM dialogs WHERE account = :account AND opponent_key = :account), - '' - ) AS opponent_username, - COALESCE( - (SELECT plain_message FROM messages - WHERE account = :account - AND from_public_key = :account - AND to_public_key = :account - ORDER BY timestamp DESC LIMIT 1), - '' - ) AS last_message, - COALESCE( - (SELECT MAX(timestamp) FROM messages - WHERE account = :account - AND from_public_key = :account - AND to_public_key = :account), - 0 - ) AS last_message_timestamp, - 0 AS unread_count, - COALESCE( - (SELECT is_online FROM dialogs WHERE account = :account AND opponent_key = :account), - 0 - ) AS is_online, - COALESCE( - (SELECT last_seen FROM dialogs WHERE account = :account AND opponent_key = :account), - 0 - ) AS last_seen, - COALESCE( - (SELECT verified FROM dialogs WHERE account = :account AND opponent_key = :account), - 0 - ) AS verified, - 1 AS i_have_sent, - 1 AS last_message_from_me, - 1 AS last_message_delivered, - 1 AS last_message_read - WHERE EXISTS ( + + /** 🚀 Получить последнее сообщение диалога по dialog_key (использует индекс) */ + @Query( + """ + SELECT * FROM messages + WHERE account = :account AND dialog_key = :dialogKey + ORDER BY timestamp DESC, id DESC LIMIT 1 + """ + ) + suspend fun getLastMessageByDialogKey(account: String, dialogKey: String): MessageEntity? + + /** 🚀 Количество непрочитанных входящих сообщений от оппонента */ + @Query( + """ + SELECT COUNT(*) FROM messages + WHERE account = :account + AND from_public_key = :opponentKey + AND to_public_key = :account + AND from_me = 0 + AND read = 0 + """ + ) + suspend fun countUnreadFromOpponent(account: String, opponentKey: String): Int + + /** 🚀 Проверить есть ли исходящие сообщения к оппоненту */ + @Query( + """ + SELECT EXISTS( SELECT 1 FROM messages WHERE account = :account AND from_public_key = :account - AND to_public_key = :account + AND to_public_key = :opponentKey + AND from_me = 1 + LIMIT 1 ) - """) - suspend fun updateSavedMessagesDialogFromMessages(account: String) + """ + ) + suspend fun hasSentToOpponent(account: String, opponentKey: String): Boolean + + /** + * 🚀 ОПТИМИЗИРОВАННЫЙ updateDialogFromMessages Заменяет монолитный SQL с 9 коррелированными + * подзапросами на: + * - 3 индексированных запроса (getLastMessageByDialogKey, countUnreadFromOpponent, + * hasSentToOpponent) + * - 1 SELECT существующего диалога для сохранения метаданных + * - 1 INSERT OR REPLACE Всё обёрнуто в транзакцию для консистентности. Использует dialog_key с + * индексом (account, dialog_key, timestamp) вместо OR-условий. + */ + @Transaction + suspend fun updateDialogFromMessages(account: String, opponentKey: String) { + val dialogKey = + if (account < opponentKey) "$account:$opponentKey" else "$opponentKey:$account" + + // 1. Получаем последнее сообщение — O(1) по индексу (account, dialog_key, timestamp) + val lastMsg = getLastMessageByDialogKey(account, dialogKey) ?: return + + // 2. Получаем существующий диалог для сохранения метаданных (online, verified, title...) + val existing = getDialog(account, opponentKey) + + // 3. Считаем непрочитанные — O(N) по индексу (account, from_public_key, to_public_key, + // timestamp) + val unread = countUnreadFromOpponent(account, opponentKey) + + // 4. Проверяем были ли исходящие — O(1) + val hasSent = hasSentToOpponent(account, opponentKey) + + // 5. Один INSERT OR REPLACE с вычисленными данными + insertDialog( + DialogEntity( + id = existing?.id ?: 0, + account = account, + opponentKey = opponentKey, + opponentTitle = existing?.opponentTitle ?: "", + opponentUsername = existing?.opponentUsername ?: "", + lastMessage = lastMsg.plainMessage, + lastMessageTimestamp = lastMsg.timestamp, + unreadCount = unread, + isOnline = existing?.isOnline ?: 0, + lastSeen = existing?.lastSeen ?: 0, + verified = existing?.verified ?: 0, + iHaveSent = if (hasSent) 1 else (existing?.iHaveSent ?: 0), + lastMessageFromMe = lastMsg.fromMe, + lastMessageDelivered = if (lastMsg.fromMe == 1) lastMsg.delivered else 0, + lastMessageRead = if (lastMsg.fromMe == 1) lastMsg.read else 0, + lastMessageAttachments = lastMsg.attachments + ) + ) + } + + /** + * 📁 ОПТИМИЗИРОВАННЫЙ updateSavedMessagesDialogFromMessages Для saved messages (opponentKey == + * account) + */ + @Transaction + suspend fun updateSavedMessagesDialogFromMessages(account: String) { + val dialogKey = "$account:$account" + + val lastMsg = getLastMessageByDialogKey(account, dialogKey) ?: return + val existing = getDialog(account, account) + + insertDialog( + DialogEntity( + id = existing?.id ?: 0, + account = account, + opponentKey = account, + opponentTitle = existing?.opponentTitle ?: "", + opponentUsername = existing?.opponentUsername ?: "", + lastMessage = lastMsg.plainMessage, + lastMessageTimestamp = lastMsg.timestamp, + unreadCount = 0, + isOnline = existing?.isOnline ?: 0, + lastSeen = existing?.lastSeen ?: 0, + verified = existing?.verified ?: 0, + iHaveSent = 1, + lastMessageFromMe = 1, + lastMessageDelivered = 1, + lastMessageRead = 1, + lastMessageAttachments = lastMsg.attachments + ) + ) + } } diff --git a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt index 764409e..546c44c 100644 --- a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt +++ b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt @@ -8,15 +8,15 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase @Database( - entities = [ - EncryptedAccountEntity::class, - MessageEntity::class, - DialogEntity::class, - BlacklistEntity::class, - AvatarCacheEntity::class - ], - version = 10, - exportSchema = false + entities = + [ + EncryptedAccountEntity::class, + MessageEntity::class, + DialogEntity::class, + BlacklistEntity::class, + AvatarCacheEntity::class], + version = 11, + exportSchema = false ) abstract class RosettaDatabase : RoomDatabase() { abstract fun accountDao(): AccountDao @@ -26,93 +26,140 @@ abstract class RosettaDatabase : RoomDatabase() { abstract fun avatarDao(): AvatarDao companion object { - @Volatile - private var INSTANCE: RosettaDatabase? = null - - private val MIGRATION_4_5 = object : Migration(4, 5) { - override fun migrate(database: SupportSQLiteDatabase) { - // Добавляем новые столбцы для индикаторов прочтения - database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_from_me INTEGER NOT NULL DEFAULT 0") - database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_delivered INTEGER NOT NULL DEFAULT 0") - database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0") - } - } - - private val MIGRATION_5_6 = object : Migration(5, 6) { - override fun migrate(database: SupportSQLiteDatabase) { - // Добавляем поле username в encrypted_accounts - database.execSQL("ALTER TABLE encrypted_accounts ADD COLUMN username TEXT") - } - } - - private val MIGRATION_6_7 = object : Migration(6, 7) { - override fun migrate(database: SupportSQLiteDatabase) { - // Создаем таблицу для кэша аватаров - database.execSQL(""" + @Volatile private var INSTANCE: RosettaDatabase? = null + + private val MIGRATION_4_5 = + object : Migration(4, 5) { + override fun migrate(database: SupportSQLiteDatabase) { + // Добавляем новые столбцы для индикаторов прочтения + database.execSQL( + "ALTER TABLE dialogs ADD COLUMN last_message_from_me INTEGER NOT NULL DEFAULT 0" + ) + database.execSQL( + "ALTER TABLE dialogs ADD COLUMN last_message_delivered INTEGER NOT NULL DEFAULT 0" + ) + database.execSQL( + "ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0" + ) + } + } + + private val MIGRATION_5_6 = + object : Migration(5, 6) { + override fun migrate(database: SupportSQLiteDatabase) { + // Добавляем поле username в encrypted_accounts + database.execSQL("ALTER TABLE encrypted_accounts ADD COLUMN username TEXT") + } + } + + private val MIGRATION_6_7 = + object : Migration(6, 7) { + override fun migrate(database: SupportSQLiteDatabase) { + // Создаем таблицу для кэша аватаров + database.execSQL( + """ CREATE TABLE IF NOT EXISTS avatar_cache ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, public_key TEXT NOT NULL, avatar TEXT NOT NULL, timestamp INTEGER NOT NULL ) - """) - database.execSQL("CREATE INDEX IF NOT EXISTS index_avatar_cache_public_key_timestamp ON avatar_cache (public_key, timestamp)") - } - } - - private val MIGRATION_7_8 = object : Migration(7, 8) { - override fun migrate(database: SupportSQLiteDatabase) { - // Удаляем таблицу avatar_delivery (больше не нужна) - database.execSQL("DROP TABLE IF EXISTS avatar_delivery") - } - } - + """ + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS index_avatar_cache_public_key_timestamp ON avatar_cache (public_key, timestamp)" + ) + } + } + + private val MIGRATION_7_8 = + object : Migration(7, 8) { + override fun migrate(database: SupportSQLiteDatabase) { + // Удаляем таблицу avatar_delivery (больше не нужна) + database.execSQL("DROP TABLE IF EXISTS avatar_delivery") + } + } + /** - * 🔥 МИГРАЦИЯ 8->9: Очищаем blob из attachments (как в desktop) - * Blob слишком большой для SQLite CursorWindow (2MB лимит) - * Просто обнуляем attachments - изображения перескачаются с CDN + * 🔥 МИГРАЦИЯ 8->9: Очищаем blob из attachments (как в desktop) Blob слишком большой для + * SQLite CursorWindow (2MB лимит) Просто обнуляем attachments - изображения перескачаются с + * CDN */ - private val MIGRATION_8_9 = object : Migration(8, 9) { - override fun migrate(database: SupportSQLiteDatabase) { - // Очищаем все attachments с большими blob'ами - // Они будут перескачаны с CDN при открытии - database.execSQL(""" + private val MIGRATION_8_9 = + object : Migration(8, 9) { + override fun migrate(database: SupportSQLiteDatabase) { + // Очищаем все attachments с большими blob'ами + // Они будут перескачаны с CDN при открытии + database.execSQL( + """ UPDATE messages SET attachments = '[]' WHERE length(attachments) > 10000 - """) - } - } - + """ + ) + } + } + /** - * 🔥 МИГРАЦИЯ 9->10: Повторная очистка blob из attachments - * Для пользователей которые уже были на версии 9 + * 🔥 МИГРАЦИЯ 9->10: Повторная очистка blob из attachments Для пользователей которые уже + * были на версии 9 */ - private val MIGRATION_9_10 = object : Migration(9, 10) { - override fun migrate(database: SupportSQLiteDatabase) { - // Очищаем все attachments с большими blob'ами - database.execSQL(""" + private val MIGRATION_9_10 = + object : Migration(9, 10) { + override fun migrate(database: SupportSQLiteDatabase) { + // Очищаем все attachments с большими blob'ами + database.execSQL( + """ UPDATE messages SET attachments = '[]' WHERE length(attachments) > 10000 - """) - } - } + """ + ) + } + } + + /** + * 🚀 МИГРАЦИЯ 10->11: Денормализация — кэш attachments последнего сообщения в dialogs + * Устраняет N+1 проблему: ранее для каждого диалога делался отдельный запрос к messages + */ + private val MIGRATION_10_11 = + object : Migration(10, 11) { + override fun migrate(database: SupportSQLiteDatabase) { + // Добавляем столбец для кэша attachments последнего сообщения + database.execSQL( + "ALTER TABLE dialogs ADD COLUMN last_message_attachments TEXT NOT NULL DEFAULT '[]'" + ) + } + } fun getDatabase(context: Context): RosettaDatabase { - return INSTANCE ?: synchronized(this) { - val instance = Room.databaseBuilder( - context.applicationContext, - RosettaDatabase::class.java, - "rosetta_secure.db" - ) - .setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance - .addMigrations(MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10) - .fallbackToDestructiveMigration() // Для разработки - только если миграция не найдена - .build() - INSTANCE = instance - instance - } + return INSTANCE + ?: synchronized(this) { + val instance = + Room.databaseBuilder( + context.applicationContext, + RosettaDatabase::class.java, + "rosetta_secure.db" + ) + .setJournalMode( + JournalMode.WRITE_AHEAD_LOGGING + ) // WAL mode for performance + .addMigrations( + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7, + MIGRATION_7_8, + MIGRATION_8_9, + MIGRATION_9_10, + MIGRATION_10_11 + ) + .fallbackToDestructiveMigration() // Для разработки - только + // если миграция не + // найдена + .build() + INSTANCE = instance + instance + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 6cbc194..22ca177 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -1,89 +1,86 @@ package com.rosetta.messenger.ui.chats import android.app.Application +import androidx.compose.runtime.Immutable import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.data.MessageRepository -import com.rosetta.messenger.database.DialogEntity -import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.BlacklistEntity +import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.PacketOnlineSubscribe import com.rosetta.messenger.network.PacketSearch import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import androidx.compose.runtime.Immutable import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -/** - * UI модель диалога с расшифрованным lastMessage - */ +/** UI модель диалога с расшифрованным lastMessage */ @Immutable data class DialogUiModel( - val id: Long, - val account: String, - val opponentKey: String, - val opponentTitle: String, - val opponentUsername: String, - val lastMessage: String, // 🔓 Расшифрованный текст - val lastMessageTimestamp: Long, - val unreadCount: Int, - val isOnline: Int, - val lastSeen: Long, - val verified: Int, - val isSavedMessages: Boolean = false, // 📁 Флаг для Saved Messages (account == opponentKey) - val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1) - val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR) - val lastMessageRead: Int = 0, // Прочитано (0/1) - val lastMessageAttachmentType: String? = null // 📎 Тип attachment: "Photo", "File", или null + val id: Long, + val account: String, + val opponentKey: String, + val opponentTitle: String, + val opponentUsername: String, + val lastMessage: String, // 🔓 Расшифрованный текст + val lastMessageTimestamp: Long, + val unreadCount: Int, + val isOnline: Int, + val lastSeen: Long, + val verified: Int, + val isSavedMessages: Boolean = false, // 📁 Флаг для Saved Messages (account == opponentKey) + val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1) + val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR) + val lastMessageRead: Int = 0, // Прочитано (0/1) + val lastMessageAttachmentType: String? = + null // 📎 Тип attachment: "Photo", "File", или null ) /** - * 🔥 Комбинированное состояние чатов для атомарного обновления UI - * Это предотвращает "дергание" когда dialogs и requests обновляются независимо + * 🔥 Комбинированное состояние чатов для атомарного обновления UI Это предотвращает "дергание" + * когда dialogs и requests обновляются независимо */ @Immutable data class ChatsUiState( - val dialogs: List = emptyList(), - val requests: List = emptyList(), - val requestsCount: Int = 0 + val dialogs: List = emptyList(), + val requests: List = emptyList(), + val requestsCount: Int = 0 ) { - val isEmpty: Boolean get() = dialogs.isEmpty() && requestsCount == 0 - val hasContent: Boolean get() = dialogs.isNotEmpty() || requestsCount > 0 + val isEmpty: Boolean + get() = dialogs.isEmpty() && requestsCount == 0 + val hasContent: Boolean + get() = dialogs.isNotEmpty() || requestsCount > 0 } -/** - * ViewModel для списка чатов - * Загружает диалоги из базы данных и расшифровывает lastMessage - */ +/** ViewModel для списка чатов Загружает диалоги из базы данных и расшифровывает lastMessage */ class ChatsListViewModel(application: Application) : AndroidViewModel(application) { - + private val database = RosettaDatabase.getDatabase(application) private val dialogDao = database.dialogDao() - private val messageDao = database.messageDao() // 🔥 Добавляем для получения статуса последнего сообщения - + private var currentAccount: String = "" private var currentPrivateKey: String? = null - + // 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы private val requestedUserInfoKeys = mutableSetOf() - + // 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл private val subscribedOnlineKeys = mutableSetOf() - + // Список диалогов с расшифрованными сообщениями private val _dialogs = MutableStateFlow>(emptyList()) val dialogs: StateFlow> = _dialogs.asStateFlow() - + // Список requests (запросы от новых пользователей) private val _requests = MutableStateFlow>(emptyList()) val requests: StateFlow> = _requests.asStateFlow() - + // Количество requests private val _requestsCount = MutableStateFlow(0) val requestsCount: StateFlow = _requestsCount.asStateFlow() @@ -91,297 +88,394 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio // Заблокированные пользователи (реактивный Set из Room Flow) private val _blockedUsers = MutableStateFlow>(emptySet()) val blockedUsers: StateFlow> = _blockedUsers.asStateFlow() - + // 🔥 НОВОЕ: Комбинированное состояние - обновляется атомарно! // 🎯 ИСПРАВЛЕНИЕ: debounce предотвращает мерцание при быстрых обновлениях - val chatsState: StateFlow = combine( - _dialogs, - _requests, - _requestsCount - ) { dialogs, requests, count -> - ChatsUiState(dialogs, requests, count) - } - .distinctUntilChanged() // 🔥 Игнорируем дублирующиеся состояния - .stateIn( - viewModelScope, - SharingStarted.Eagerly, // 🔥 КРИТИЧНО: Eagerly вместо WhileSubscribed - сразу начинаем следить - ChatsUiState() - ) - + val chatsState: StateFlow = + combine(_dialogs, _requests, _requestsCount) { dialogs, requests, count -> + ChatsUiState(dialogs, requests, count) + } + .distinctUntilChanged() // 🔥 Игнорируем дублирующиеся состояния + .stateIn( + viewModelScope, + SharingStarted + .Eagerly, // 🔥 КРИТИЧНО: Eagerly вместо WhileSubscribed - сразу + // начинаем следить + ChatsUiState() + ) + // Загрузка private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading.asStateFlow() - + private val TAG = "ChatsListVM" - - /** - * Установить текущий аккаунт и загрузить диалоги - */ + + /** Установить текущий аккаунт и загрузить диалоги */ fun setAccount(publicKey: String, privateKey: String) { val setAccountStart = System.currentTimeMillis() -if (currentAccount == publicKey) { + if (currentAccount == publicKey) { return } - + // 🔥 Очищаем кэш запрошенных user info при смене аккаунта requestedUserInfoKeys.clear() - + currentAccount = publicKey currentPrivateKey = privateKey // Подписываемся на обычные диалоги + @OptIn(FlowPreview::class) viewModelScope.launch { - dialogDao.getDialogsFlow(publicKey) - .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO - .map { dialogsList -> - val mapStart = System.currentTimeMillis() - // � ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений - withContext(Dispatchers.Default) { - dialogsList.map { dialog -> - async { - // 🔥 Загружаем информацию о пользователе если её нет - // 📁 НЕ загружаем для Saved Messages - val isSavedMessages = (dialog.account == dialog.opponentKey) - if (!isSavedMessages && (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey || dialog.opponentTitle == dialog.opponentKey.take(7))) { - loadUserInfoForDialog(dialog.opponentKey) - } - - // 🚀 Расшифровка теперь кэшируется в CryptoManager! - val decryptedLastMessage = try { - if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) { - CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey) - ?: dialog.lastMessage - } else { - dialog.lastMessage - } - } catch (e: Exception) { - dialog.lastMessage // Fallback на зашифрованный текст - } - - // 🔥🔥🔥 НОВЫЙ ПОДХОД: Получаем статус НАПРЯМУЮ из таблицы messages - // Это гарантирует синхронизацию с тем что показывается в диалоге - val lastMsgStatus = messageDao.getLastMessageStatus(publicKey, dialog.opponentKey) - val actualFromMe = lastMsgStatus?.fromMe ?: 0 - val actualDelivered = if (actualFromMe == 1) (lastMsgStatus?.delivered ?: 0) else 0 - val actualRead = if (actualFromMe == 1) (lastMsgStatus?.read ?: 0) else 0 - - // 📎 Определяем тип attachment последнего сообщения - // 🔥 Reply vs Forward: если есть текст - это Reply (показываем текст), - // если текст пустой - это Forward (показываем "Forwarded message") - val attachmentType = try { - val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey) - if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") { - val attachments = org.json.JSONArray(attachmentsJson) - if (attachments.length() > 0) { - val firstAttachment = attachments.getJSONObject(0) - val type = firstAttachment.optInt("type", -1) - when (type) { - 0 -> "Photo" // AttachmentType.IMAGE = 0 - 1 -> { - // AttachmentType.MESSAGES = 1 (Reply или Forward) - // Reply: есть текст сообщения -> показываем текст (null) - // Forward: текст пустой -> показываем "Forwarded" - if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" - } - 2 -> "File" // AttachmentType.FILE = 2 - 3 -> "Avatar" // AttachmentType.AVATAR = 3 - else -> null + dialogDao + .getDialogsFlow(publicKey) + .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO + .debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка) + .map { dialogsList -> + val mapStart = System.currentTimeMillis() + // � ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений + withContext(Dispatchers.Default) { + dialogsList + .map { dialog -> + async { + // 🔥 Загружаем информацию о пользователе если её нет + // 📁 НЕ загружаем для Saved Messages + val isSavedMessages = + (dialog.account == dialog.opponentKey) + if (!isSavedMessages && + (dialog.opponentTitle.isEmpty() || + dialog.opponentTitle == + dialog.opponentKey || + dialog.opponentTitle == + dialog.opponentKey.take( + 7 + )) + ) { + loadUserInfoForDialog(dialog.opponentKey) } - } else null - } else null - } catch (e: Exception) { - null - } - DialogUiModel( - id = dialog.id, - account = dialog.account, - opponentKey = dialog.opponentKey, - opponentTitle = dialog.opponentTitle, - opponentUsername = dialog.opponentUsername, - lastMessage = decryptedLastMessage, - lastMessageTimestamp = dialog.lastMessageTimestamp, - unreadCount = dialog.unreadCount, - isOnline = dialog.isOnline, - lastSeen = dialog.lastSeen, - verified = dialog.verified, - isSavedMessages = isSavedMessages, // 📁 Saved Messages - lastMessageFromMe = actualFromMe, // 🔥 Используем актуальные данные из messages - lastMessageDelivered = actualDelivered, // 🔥 Используем актуальные данные из messages - lastMessageRead = actualRead, // 🔥 Используем актуальные данные из messages - lastMessageAttachmentType = attachmentType // 📎 Тип attachment - ) - } - }.awaitAll() - }.also { - val mapTime = System.currentTimeMillis() - mapStart -} - } - .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки - .collect { decryptedDialogs -> - _dialogs.value = decryptedDialogs - - // 🟢 Подписываемся на онлайн-статусы всех собеседников - // 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный статус - val opponentsToSubscribe = decryptedDialogs - .filter { !it.isSavedMessages } - .map { it.opponentKey } - subscribeToOnlineStatuses(opponentsToSubscribe, privateKey) - } - } - - // 📬 Подписываемся на requests (запросы от новых пользователей) - viewModelScope.launch { - dialogDao.getRequestsFlow(publicKey) - .flowOn(Dispatchers.IO) - .map { requestsList -> - // 🚀 ОПТИМИЗАЦИЯ: Параллельная расшифровка - withContext(Dispatchers.Default) { - requestsList.map { dialog -> - async { - // 🔥 Загружаем информацию о пользователе если её нет - // 📁 НЕ загружаем для Saved Messages - val isSavedMessages = (dialog.account == dialog.opponentKey) - if (!isSavedMessages && (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey)) { - loadUserInfoForRequest(dialog.opponentKey) - } - - // 🚀 Расшифровка теперь кэшируется в CryptoManager! - val decryptedLastMessage = try { - if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) { - CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey) - ?: dialog.lastMessage - } else { - dialog.lastMessage + // 🚀 Расшифровка теперь кэшируется в CryptoManager! + val decryptedLastMessage = + try { + if (privateKey.isNotEmpty() && + dialog.lastMessage + .isNotEmpty() + ) { + CryptoManager.decryptWithPassword( + dialog.lastMessage, + privateKey + ) + ?: dialog.lastMessage + } else { + dialog.lastMessage + } + } catch (e: Exception) { + dialog.lastMessage // Fallback на + // зашифрованный текст + } + + // � ОПТИМИЗАЦИЯ: Используем денормализованные поля из + // DialogEntity + // Статус и attachments уже записаны в dialogs через + // updateDialogFromMessages() + // Это устраняет N+1 проблему (ранее: 2 запроса на + // каждый диалог) + + // 📎 Определяем тип attachment из кэшированного поля в + // DialogEntity + val attachmentType = + try { + val attachmentsJson = + dialog.lastMessageAttachments + if (attachmentsJson.isNotEmpty() && + attachmentsJson != "[]" + ) { + val attachments = + org.json.JSONArray( + attachmentsJson + ) + if (attachments.length() > 0) { + val firstAttachment = + attachments.getJSONObject(0) + val type = + firstAttachment.optInt( + "type", + -1 + ) + when (type) { + 0 -> + "Photo" // AttachmentType.IMAGE = 0 + 1 -> { + // AttachmentType.MESSAGES = + // 1 (Reply или Forward) + // Reply: есть текст + // сообщения -> показываем + // текст (null) + // Forward: текст пустой -> + // показываем "Forwarded" + if (decryptedLastMessage + .isNotEmpty() + ) + null + else "Forwarded" + } + 2 -> + "File" // AttachmentType.FILE = 2 + 3 -> + "Avatar" // AttachmentType.AVATAR = 3 + else -> null + } + } else null + } else null + } catch (e: Exception) { + null + } + + DialogUiModel( + id = dialog.id, + account = dialog.account, + opponentKey = dialog.opponentKey, + opponentTitle = dialog.opponentTitle, + opponentUsername = dialog.opponentUsername, + lastMessage = decryptedLastMessage, + lastMessageTimestamp = + dialog.lastMessageTimestamp, + unreadCount = dialog.unreadCount, + isOnline = dialog.isOnline, + lastSeen = dialog.lastSeen, + verified = dialog.verified, + isSavedMessages = + isSavedMessages, // 📁 Saved Messages + lastMessageFromMe = + dialog.lastMessageFromMe, // 🚀 Из + // DialogEntity (денормализовано) + lastMessageDelivered = + dialog.lastMessageDelivered, // 🚀 Из + // DialogEntity (денормализовано) + lastMessageRead = + dialog.lastMessageRead, // 🚀 Из + // DialogEntity + // (денормализовано) + lastMessageAttachmentType = + attachmentType // 📎 Тип attachment + ) + } } - } catch (e: Exception) { - dialog.lastMessage + .awaitAll() + } + .also { + val mapTime = System.currentTimeMillis() - mapStart } - - // 📎 Определяем тип attachment последнего сообщения - // 🔥 Reply vs Forward: если есть текст - это Reply (показываем текст), - // если текст пустой - это Forward (показываем "Forwarded message") - val attachmentType = try { - val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey) - if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") { - val attachments = org.json.JSONArray(attachmentsJson) - if (attachments.length() > 0) { - val firstAttachment = attachments.getJSONObject(0) - val type = firstAttachment.optInt("type", -1) - when (type) { - 0 -> "Photo" // AttachmentType.IMAGE = 0 - 1 -> { - // AttachmentType.MESSAGES = 1 (Reply или Forward) - // Reply: есть текст сообщения -> показываем текст (null) - // Forward: текст пустой -> показываем "Forwarded" - if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" - } - 2 -> "File" // AttachmentType.FILE = 2 - 3 -> "Avatar" // AttachmentType.AVATAR = 3 - else -> null - } - } else null - } else null - } catch (e: Exception) { - null - } - - DialogUiModel( - id = dialog.id, - account = dialog.account, - opponentKey = dialog.opponentKey, - opponentTitle = dialog.opponentTitle, // 🔥 Показываем имя как в обычных чатах - opponentUsername = dialog.opponentUsername, - lastMessage = decryptedLastMessage, - lastMessageTimestamp = dialog.lastMessageTimestamp, - unreadCount = dialog.unreadCount, - isOnline = dialog.isOnline, - lastSeen = dialog.lastSeen, - verified = dialog.verified, - isSavedMessages = (dialog.account == dialog.opponentKey), // 📁 Saved Messages - lastMessageFromMe = dialog.lastMessageFromMe, - lastMessageDelivered = dialog.lastMessageDelivered, - lastMessageRead = dialog.lastMessageRead, - lastMessageAttachmentType = attachmentType // 📎 Тип attachment - ) - } - }.awaitAll() } - } - .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки - .collect { decryptedRequests -> - _requests.value = decryptedRequests - } + .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки + .collect { decryptedDialogs -> + _dialogs.value = decryptedDialogs + + // 🟢 Подписываемся на онлайн-статусы всех собеседников + // 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный + // статус + val opponentsToSubscribe = + decryptedDialogs.filter { !it.isSavedMessages }.map { + it.opponentKey + } + subscribeToOnlineStatuses(opponentsToSubscribe, privateKey) + } } - + + // 📬 Подписываемся на requests (запросы от новых пользователей) + @OptIn(FlowPreview::class) + viewModelScope.launch { + dialogDao + .getRequestsFlow(publicKey) + .flowOn(Dispatchers.IO) + .debounce(100) // 🚀 Батчим быстрые обновления + .map { requestsList -> + // 🚀 ОПТИМИЗАЦИЯ: Параллельная расшифровка + withContext(Dispatchers.Default) { + requestsList + .map { dialog -> + async { + // 🔥 Загружаем информацию о пользователе если её нет + // 📁 НЕ загружаем для Saved Messages + val isSavedMessages = + (dialog.account == dialog.opponentKey) + if (!isSavedMessages && + (dialog.opponentTitle.isEmpty() || + dialog.opponentTitle == + dialog.opponentKey) + ) { + loadUserInfoForRequest(dialog.opponentKey) + } + + // 🚀 Расшифровка теперь кэшируется в CryptoManager! + val decryptedLastMessage = + try { + if (privateKey.isNotEmpty() && + dialog.lastMessage + .isNotEmpty() + ) { + CryptoManager.decryptWithPassword( + dialog.lastMessage, + privateKey + ) + ?: dialog.lastMessage + } else { + dialog.lastMessage + } + } catch (e: Exception) { + dialog.lastMessage + } + + // 📎 Определяем тип attachment из кэшированного поля в + // DialogEntity + val attachmentType = + try { + val attachmentsJson = + dialog.lastMessageAttachments + if (attachmentsJson.isNotEmpty() && + attachmentsJson != "[]" + ) { + val attachments = + org.json.JSONArray( + attachmentsJson + ) + if (attachments.length() > 0) { + val firstAttachment = + attachments.getJSONObject(0) + val type = + firstAttachment.optInt( + "type", + -1 + ) + when (type) { + 0 -> + "Photo" // AttachmentType.IMAGE = 0 + 1 -> { + // AttachmentType.MESSAGES = + // 1 (Reply или Forward) + // Reply: есть текст + // сообщения -> показываем + // текст (null) + // Forward: текст пустой -> + // показываем "Forwarded" + if (decryptedLastMessage + .isNotEmpty() + ) + null + else "Forwarded" + } + 2 -> + "File" // AttachmentType.FILE = 2 + 3 -> + "Avatar" // AttachmentType.AVATAR = 3 + else -> null + } + } else null + } else null + } catch (e: Exception) { + null + } + + DialogUiModel( + id = dialog.id, + account = dialog.account, + opponentKey = dialog.opponentKey, + opponentTitle = + dialog.opponentTitle, // 🔥 Показываем + // имя как в + // обычных чатах + opponentUsername = dialog.opponentUsername, + lastMessage = decryptedLastMessage, + lastMessageTimestamp = + dialog.lastMessageTimestamp, + unreadCount = dialog.unreadCount, + isOnline = dialog.isOnline, + lastSeen = dialog.lastSeen, + verified = dialog.verified, + isSavedMessages = + (dialog.account == + dialog.opponentKey), // 📁 Saved + // Messages + lastMessageFromMe = dialog.lastMessageFromMe, + lastMessageDelivered = + dialog.lastMessageDelivered, + lastMessageRead = dialog.lastMessageRead, + lastMessageAttachmentType = + attachmentType // 📎 Тип attachment + ) + } + } + .awaitAll() + } + } + .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки + .collect { decryptedRequests -> _requests.value = decryptedRequests } + } + // 📊 Подписываемся на количество requests viewModelScope.launch { - dialogDao.getRequestsCountFlow(publicKey) - .flowOn(Dispatchers.IO) - .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения - .collect { count -> - _requestsCount.value = count - } + dialogDao + .getRequestsCountFlow(publicKey) + .flowOn(Dispatchers.IO) + .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения + .collect { count -> _requestsCount.value = count } } - // 🔥 Подписываемся на блоклист — Room автоматически переэмитит при blockUser()/unblockUser() + // 🔥 Подписываемся на блоклист — Room автоматически переэмитит при + // blockUser()/unblockUser() viewModelScope.launch { - database.blacklistDao().getBlockedUsers(publicKey) - .flowOn(Dispatchers.IO) - .map { entities -> entities.map { it.publicKey }.toSet() } - .distinctUntilChanged() - .collect { blockedSet -> - _blockedUsers.value = blockedSet - } + database.blacklistDao() + .getBlockedUsers(publicKey) + .flowOn(Dispatchers.IO) + .map { entities -> entities.map { it.publicKey }.toSet() } + .distinctUntilChanged() + .collect { blockedSet -> _blockedUsers.value = blockedSet } } } - + /** - * 🟢 Подписаться на онлайн-статусы всех собеседников - * 🔥 Фильтруем уже подписанные ключи чтобы избежать бесконечного цикла + * 🟢 Подписаться на онлайн-статусы всех собеседников 🔥 Фильтруем уже подписанные ключи чтобы + * избежать бесконечного цикла */ private fun subscribeToOnlineStatuses(opponentKeys: List, privateKey: String) { if (opponentKeys.isEmpty()) return - + // 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи! val newKeys = opponentKeys.filter { !subscribedOnlineKeys.contains(it) } - if (newKeys.isEmpty()) return // Все уже подписаны - + if (newKeys.isEmpty()) return // Все уже подписаны + // Добавляем в Set ДО отправки пакета чтобы избежать race condition subscribedOnlineKeys.addAll(newKeys) - + viewModelScope.launch(Dispatchers.IO) { try { val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) - - val packet = PacketOnlineSubscribe().apply { - this.privateKey = privateKeyHash - newKeys.forEach { key -> - addPublicKey(key) - } - } - + + val packet = + PacketOnlineSubscribe().apply { + this.privateKey = privateKeyHash + newKeys.forEach { key -> addPublicKey(key) } + } + ProtocolManager.send(packet) - } catch (e: Exception) { - } + } catch (e: Exception) {} } } - + /** - * Создать или обновить диалог после отправки/получения сообщения - * 🔥 Используем updateDialogFromMessages для пересчета счетчиков из messages - * 📁 SAVED MESSAGES: Использует специальный метод для saved messages + * Создать или обновить диалог после отправки/получения сообщения 🔥 Используем + * updateDialogFromMessages для пересчета счетчиков из messages 📁 SAVED MESSAGES: Использует + * специальный метод для saved messages */ suspend fun upsertDialog( - opponentKey: String, - opponentTitle: String, - opponentUsername: String = "", - lastMessage: String, - timestamp: Long, - verified: Int = 0, - isOnline: Int = 0 + opponentKey: String, + opponentTitle: String, + opponentUsername: String = "", + lastMessage: String, + timestamp: Long, + verified: Int = 0, + isOnline: Int = 0 ) { if (currentAccount.isEmpty()) return - + try { // 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики // напрямую из таблицы messages, как в Архиве! @@ -391,36 +485,38 @@ if (currentAccount == publicKey) { } else { dialogDao.updateDialogFromMessages(currentAccount, opponentKey) } - + // Обновляем информацию о собеседнике если есть if (opponentTitle.isNotEmpty()) { - dialogDao.updateOpponentInfo(currentAccount, opponentKey, opponentTitle, opponentUsername, verified) + dialogDao.updateOpponentInfo( + currentAccount, + opponentKey, + opponentTitle, + opponentUsername, + verified + ) } - - } catch (e: Exception) { - } + } catch (e: Exception) {} } - - /** - * Конвертировать DialogUiModel в SearchUser для навигации - */ + + /** Конвертировать DialogUiModel в SearchUser для навигации */ fun dialogToSearchUser(dialog: DialogUiModel): SearchUser { return SearchUser( - title = dialog.opponentTitle, - username = dialog.opponentUsername, - publicKey = dialog.opponentKey, - verified = dialog.verified, - online = dialog.isOnline + title = dialog.opponentTitle, + username = dialog.opponentUsername, + publicKey = dialog.opponentKey, + verified = dialog.verified, + online = dialog.isOnline ) } - + /** - * Удалить диалог и все сообщения с собеседником - * 🔥 ПОЛНОСТЬЮ очищает все данные - диалог, сообщения, кэш + * Удалить диалог и все сообщения с собеседником 🔥 ПОЛНОСТЬЮ очищает все данные - диалог, + * сообщения, кэш */ suspend fun deleteDialog(opponentKey: String) { if (currentAccount.isEmpty()) return - + try { // 🚀 Сразу обновляем UI - удаляем диалог из локального списка _dialogs.value = _dialogs.value.filter { it.opponentKey != opponentKey } @@ -428,135 +524,127 @@ if (currentAccount == publicKey) { _requests.value = _requests.value.filter { it.opponentKey != opponentKey } // 🔥 Обновляем счетчик requests _requestsCount.value = _requests.value.size - + // Вычисляем правильный dialog_key (отсортированная комбинация ключей) - val dialogKey = if (currentAccount < opponentKey) { - "$currentAccount:$opponentKey" - } else { - "$opponentKey:$currentAccount" - } - + val dialogKey = + if (currentAccount < opponentKey) { + "$currentAccount:$opponentKey" + } else { + "$opponentKey:$currentAccount" + } + // 🗑️ 1. Очищаем ВСЕ кэши сообщений MessageRepository.getInstance(getApplication()).clearDialogCache(opponentKey) // 🗑️ Очищаем кэш ChatViewModel ChatViewModel.clearCacheForOpponent(opponentKey) - + // 🗑️ 2. Проверяем сколько сообщений в БД до удаления - val messageCountBefore = database.messageDao().getMessageCount(currentAccount, dialogKey) - + val messageCountBefore = + database.messageDao().getMessageCount(currentAccount, dialogKey) + // 🗑️ 3. Удаляем все сообщения из диалога по dialog_key - val deletedByDialogKey = database.messageDao().deleteDialog( - account = currentAccount, - dialogKey = dialogKey - ) - + val deletedByDialogKey = + database.messageDao() + .deleteDialog(account = currentAccount, dialogKey = dialogKey) + // 🗑️ 4. Также удаляем по from/to ключам (на всякий случай - старые сообщения) - val deletedBetweenUsers = database.messageDao().deleteMessagesBetweenUsers( - account = currentAccount, - user1 = opponentKey, - user2 = currentAccount - ) - + val deletedBetweenUsers = + database.messageDao() + .deleteMessagesBetweenUsers( + account = currentAccount, + user1 = opponentKey, + user2 = currentAccount + ) + // 🗑️ 5. Проверяем сколько сообщений осталось val messageCountAfter = database.messageDao().getMessageCount(currentAccount, dialogKey) - + // 🗑️ 6. Удаляем диалог из таблицы dialogs - database.dialogDao().deleteDialog( - account = currentAccount, - opponentKey = opponentKey - ) - + database.dialogDao().deleteDialog(account = currentAccount, opponentKey = opponentKey) + // 🗑️ 7. Проверяем что диалог удален val dialogAfter = database.dialogDao().getDialog(currentAccount, opponentKey) - } catch (e: Exception) { // В случае ошибки - возвращаем диалог обратно (откатываем оптимистичное обновление) // Flow обновится автоматически из БД } } - - /** - * Заблокировать пользователя - */ + + /** Заблокировать пользователя */ suspend fun blockUser(publicKey: String) { if (currentAccount.isEmpty()) return - + try { - database.blacklistDao().blockUser( - com.rosetta.messenger.database.BlacklistEntity( - publicKey = publicKey, - account = currentAccount - ) - ) - } catch (e: Exception) { - } + database.blacklistDao() + .blockUser( + com.rosetta.messenger.database.BlacklistEntity( + publicKey = publicKey, + account = currentAccount + ) + ) + } catch (e: Exception) {} } - - /** - * Разблокировать пользователя - */ + + /** Разблокировать пользователя */ suspend fun unblockUser(publicKey: String) { if (currentAccount.isEmpty()) return - + try { database.blacklistDao().unblockUser(publicKey, currentAccount) - } catch (e: Exception) { - } + } catch (e: Exception) {} } - + /** - * 📬 Загрузить информацию о пользователе для request - * 📁 НЕ загружаем для Saved Messages (свой publicKey) + * 📬 Загрузить информацию о пользователе для request 📁 НЕ загружаем для Saved Messages (свой + * publicKey) */ private fun loadUserInfoForRequest(publicKey: String) { loadUserInfoForDialog(publicKey) } - + /** - * 🔥 Загрузить информацию о пользователе для диалога - * 📁 НЕ загружаем для Saved Messages (свой publicKey) + * 🔥 Загрузить информацию о пользователе для диалога 📁 НЕ загружаем для Saved Messages (свой + * publicKey) */ private fun loadUserInfoForDialog(publicKey: String) { // 📁 Не запрашиваем информацию о самом себе (Saved Messages) if (publicKey == currentAccount) { return } - + // 🔥 Не запрашиваем если уже запрашивали if (requestedUserInfoKeys.contains(publicKey)) { return } requestedUserInfoKeys.add(publicKey) - - + viewModelScope.launch(Dispatchers.IO) { try { - val sharedPrefs = getApplication().getSharedPreferences("rosetta", Application.MODE_PRIVATE) + val sharedPrefs = + getApplication() + .getSharedPreferences("rosetta", Application.MODE_PRIVATE) val currentUserPrivateKey = sharedPrefs.getString("private_key", "") ?: "" - + if (currentUserPrivateKey.isEmpty()) return@launch - + // 🔥 ВАЖНО: Используем хеш ключа, как в MessageRepository.requestUserInfo val privateKeyHash = CryptoManager.generatePrivateKeyHash(currentUserPrivateKey) - - + // Запрашиваем информацию о пользователе с сервера - val packet = PacketSearch().apply { - this.privateKey = privateKeyHash - this.search = publicKey - } + val packet = + PacketSearch().apply { + this.privateKey = privateKeyHash + this.search = publicKey + } ProtocolManager.send(packet) - } catch (e: Exception) { - } + } catch (e: Exception) {} } } - - /** - * Проверить заблокирован ли пользователь - */ + + /** Проверить заблокирован ли пользователь */ suspend fun isUserBlocked(publicKey: String): Boolean { if (currentAccount.isEmpty()) return false - + return try { database.blacklistDao().isUserBlocked(publicKey, currentAccount) } catch (e: Exception) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt index b1b2a51..bd78890 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt @@ -1,5 +1,7 @@ package com.rosetta.messenger.ui.components +import android.content.Context +import android.view.inputmethod.InputMethodManager import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.gestures.* @@ -16,16 +18,14 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp -import android.content.Context -import android.view.inputmethod.InputMethodManager import kotlinx.coroutines.launch // Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0) private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) // Swipe-back thresholds (Telegram-like) -private const val COMPLETION_THRESHOLD = 0.2f // 20% of screen width — very easy to complete -private const val FLING_VELOCITY_THRESHOLD = 150f // px/s — very sensitive to flings +private const val COMPLETION_THRESHOLD = 0.2f // 20% of screen width — very easy to complete +private const val FLING_VELOCITY_THRESHOLD = 150f // px/s — very sensitive to flings private const val ANIMATION_DURATION_ENTER = 300 private const val ANIMATION_DURATION_EXIT = 200 private const val EDGE_ZONE_DP = 200 @@ -33,8 +33,7 @@ private const val EDGE_ZONE_DP = 200 /** * Telegram-style swipe back container (optimized) * - * Wraps content and allows swiping from the left edge to go back. - * Features: + * Wraps content and allows swiping from the left edge to go back. Features: * - Edge-only swipe detection (left 30dp) * - Direct state update during drag (no coroutine overhead) * - VelocityTracker for fling detection @@ -45,12 +44,18 @@ private const val EDGE_ZONE_DP = 200 */ @Composable fun SwipeBackContainer( - isVisible: Boolean, - onBack: () -> Unit, - isDarkTheme: Boolean, - swipeEnabled: Boolean = true, - content: @Composable () -> Unit + isVisible: Boolean, + onBack: () -> Unit, + isDarkTheme: Boolean, + swipeEnabled: Boolean = true, + content: @Composable () -> Unit ) { + // 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time. + // Saves ~15 remember/Animatable allocations per unused screen at startup (×12 screens). + var wasEverVisible by remember { mutableStateOf(false) } + if (isVisible) wasEverVisible = true + if (!wasEverVisible) return + val density = LocalDensity.current val configuration = LocalConfiguration.current val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() } @@ -74,7 +79,8 @@ fun SwipeBackContainer( // Coroutine scope for animations val scope = rememberCoroutineScope() - // 🔥 Keyboard controller для скрытия клавиатуры при свайпе (надёжный метод через InputMethodManager) + // 🔥 Keyboard controller для скрытия клавиатуры при свайпе (надёжный метод через + // InputMethodManager) val context = LocalContext.current val view = LocalView.current val focusManager = LocalFocusManager.current @@ -94,14 +100,15 @@ fun SwipeBackContainer( // Animate in: fade-in shouldShow = true isAnimatingIn = true - offsetAnimatable.snapTo(0f) // No slide for entry + offsetAnimatable.snapTo(0f) // No slide for entry alphaAnimatable.snapTo(0f) alphaAnimatable.animateTo( - targetValue = 1f, - animationSpec = tween( - durationMillis = ANIMATION_DURATION_ENTER, - easing = FastOutSlowInEasing - ) + targetValue = 1f, + animationSpec = + tween( + durationMillis = ANIMATION_DURATION_ENTER, + easing = FastOutSlowInEasing + ) ) isAnimatingIn = false } else if (!isVisible && shouldShow && !isAnimatingOut) { @@ -109,11 +116,8 @@ fun SwipeBackContainer( isAnimatingOut = true alphaAnimatable.snapTo(1f) alphaAnimatable.animateTo( - targetValue = 0f, - animationSpec = tween( - durationMillis = 200, - easing = FastOutSlowInEasing - ) + targetValue = 0f, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing) ) shouldShow = false isAnimatingOut = false @@ -128,133 +132,176 @@ fun SwipeBackContainer( Box(modifier = Modifier.fillMaxSize()) { // Scrim (dimming layer behind the screen) - only when swiping if (currentOffset > 0f) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = scrimAlpha)) - ) + Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = scrimAlpha))) } // Content with swipe gesture Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - translationX = currentOffset - alpha = currentAlpha - } - .background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White) - .then( - if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) { - Modifier.pointerInput(Unit) { - val velocityTracker = VelocityTracker() - val touchSlop = viewConfiguration.touchSlop - - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - - // Edge-only detection - if (down.position.x > edgeZonePx) { - return@awaitEachGesture + modifier = + Modifier.fillMaxSize() + .graphicsLayer { + translationX = currentOffset + alpha = currentAlpha } + .background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White) + .then( + if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) { + Modifier.pointerInput(Unit) { + val velocityTracker = VelocityTracker() + val touchSlop = viewConfiguration.touchSlop - velocityTracker.resetTracking() - var startedSwipe = false - var totalDragX = 0f - var totalDragY = 0f - var passedSlop = false + awaitEachGesture { + val down = + awaitFirstDown( + requireUnconsumed = false + ) - // Use Initial pass to intercept BEFORE children - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - val change = event.changes.firstOrNull { it.id == down.id } - ?: break + // Edge-only detection + if (down.position.x > edgeZonePx) { + return@awaitEachGesture + } - if (change.changedToUpIgnoreConsumed()) { - break - } + velocityTracker.resetTracking() + var startedSwipe = false + var totalDragX = 0f + var totalDragY = 0f + var passedSlop = false - val dragDelta = change.positionChange() - totalDragX += dragDelta.x - totalDragY += dragDelta.y + // Use Initial pass to intercept BEFORE children + while (true) { + val event = + awaitPointerEvent( + PointerEventPass.Initial + ) + val change = + event.changes.firstOrNull { + it.id == down.id + } + ?: break - if (!passedSlop) { - val totalDistance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY) - if (totalDistance < touchSlop) continue + if (change.changedToUpIgnoreConsumed()) { + break + } - // Slop exceeded — only claim rightward + mostly horizontal - if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.5f) { - passedSlop = true - startedSwipe = true - isDragging = true - dragOffset = offsetAnimatable.value + val dragDelta = change.positionChange() + totalDragX += dragDelta.x + totalDragY += dragDelta.y - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus() + if (!passedSlop) { + val totalDistance = + kotlin.math.sqrt( + totalDragX * + totalDragX + + totalDragY * + totalDragY + ) + if (totalDistance < touchSlop) continue - change.consume() + // Slop exceeded — only claim rightward + // + mostly horizontal + if (totalDragX > 0 && + kotlin.math.abs( + totalDragX + ) > + kotlin.math.abs( + totalDragY + ) * 1.5f + ) { + passedSlop = true + startedSwipe = true + isDragging = true + dragOffset = offsetAnimatable.value + + val imm = + context.getSystemService( + Context.INPUT_METHOD_SERVICE + ) as + InputMethodManager + imm.hideSoftInputFromWindow( + view.windowToken, + 0 + ) + focusManager.clearFocus() + + change.consume() + } else { + // Vertical or leftward — let + // children handle + break + } + } else { + // We own the gesture — update drag + dragOffset = + (dragOffset + dragDelta.x) + .coerceIn( + 0f, + screenWidthPx + ) + velocityTracker.addPosition( + change.uptimeMillis, + change.position + ) + change.consume() + } + } + + // Handle drag end + if (startedSwipe) { + isDragging = false + val velocity = + velocityTracker.calculateVelocity() + .x + val currentProgress = + dragOffset / screenWidthPx + + val shouldComplete = + currentProgress > + 0.5f || // Past 50% — always + // complete + velocity > + FLING_VELOCITY_THRESHOLD || // Fast fling right + (currentProgress > + COMPLETION_THRESHOLD && + velocity > + -FLING_VELOCITY_THRESHOLD) // 20%+ and not flinging back + + scope.launch { + offsetAnimatable.snapTo(dragOffset) + + if (shouldComplete) { + offsetAnimatable.animateTo( + targetValue = screenWidthPx, + animationSpec = + tween( + durationMillis = + ANIMATION_DURATION_EXIT, + easing = + TelegramEasing + ) + ) + onBack() + } else { + offsetAnimatable.animateTo( + targetValue = 0f, + animationSpec = + tween( + durationMillis = + ANIMATION_DURATION_EXIT, + easing = + TelegramEasing + ) + ) + } + + dragOffset = 0f + } + } + } + } } else { - // Vertical or leftward — let children handle - break + Modifier } - } else { - // We own the gesture — update drag - dragOffset = (dragOffset + dragDelta.x) - .coerceIn(0f, screenWidthPx) - velocityTracker.addPosition( - change.uptimeMillis, - change.position - ) - change.consume() - } - } - - // Handle drag end - if (startedSwipe) { - isDragging = false - val velocity = velocityTracker.calculateVelocity().x - val currentProgress = dragOffset / screenWidthPx - - val shouldComplete = - currentProgress > 0.5f || // Past 50% — always complete - velocity > FLING_VELOCITY_THRESHOLD || // Fast fling right - (currentProgress > COMPLETION_THRESHOLD && - velocity > -FLING_VELOCITY_THRESHOLD) // 20%+ and not flinging back - - scope.launch { - offsetAnimatable.snapTo(dragOffset) - - if (shouldComplete) { - offsetAnimatable.animateTo( - targetValue = screenWidthPx, - animationSpec = tween( - durationMillis = ANIMATION_DURATION_EXIT, - easing = TelegramEasing - ) - ) - onBack() - } else { - offsetAnimatable.animateTo( - targetValue = 0f, - animationSpec = tween( - durationMillis = ANIMATION_DURATION_EXIT, - easing = TelegramEasing - ) - ) - } - - dragOffset = 0f - } - } - } - } - } else { - Modifier - } - ) - ) { - content() - } + ) + ) { content() } } }