package com.rosetta.messenger.database import androidx.room.* import kotlinx.coroutines.flow.Flow /** 🔥 Data class для статуса последнего сообщения */ data class LastMessageStatus( @ColumnInfo(name = "from_me") val fromMe: Int, @ColumnInfo(name = "delivered") val delivered: Int, @ColumnInfo(name = "read") val read: Int ) /** 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"])] ) 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 // Ключ диалога для быстрой выборки ) /** Entity для диалогов (кэш последнего сообщения) */ @Entity( 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) @ColumnInfo(name = "last_message_attachments", defaultValue = "[]") val lastMessageAttachments: String = "[]" // 📎 JSON attachments последнего сообщения (кэш из messages) ) /** DAO для работы с сообщениями */ @Dao interface MessageDao { /** Вставка нового сообщения (IGNORE если уже существует) */ @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertMessage(message: MessageEntity): Long /** Вставка нескольких сообщений (IGNORE если уже существуют) */ @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertMessages(messages: List) /** Получить сообщения диалога (постранично) */ @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 /** * 📁 Получить сообщения для Saved Messages (постранично) Специальный метод для случая когда * from_public_key = to_public_key = account Использует упрощенный запрос без дублирования OR * условий */ @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( """ 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( """ SELECT * FROM messages WHERE account = :account AND dialog_key = :dialogKey ORDER BY timestamp ASC """ ) fun getMessagesFlow(account: String, dialogKey: String): Flow> /** Получить количество сообщений в диалоге */ @Query( """ SELECT COUNT(*) FROM messages WHERE account = :account AND dialog_key = :dialogKey """ ) suspend fun getMessageCount(account: String, dialogKey: String): Int /** * 📸 Получить количество сообщений между двумя пользователями (для проверки первого сообщения * при отправке аватара) */ @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( """ 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 */ @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" ) 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 ) /** Обновить статус прочтения */ @Query("UPDATE messages SET read = 1 WHERE account = :account AND message_id = :messageId") suspend fun markAsRead(account: String, messageId: String) /** Отметить все сообщения диалога как прочитанные */ @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) Ищет с допуском по * времени для учета возможных рассинхронизаций */ @Query( """ SELECT * FROM messages WHERE account = :account AND dialog_key = :dialogKey AND from_public_key = :fromPublicKey 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 ): MessageEntity? /** * Получить количество непрочитанных сообщений для диалога Считает только входящие сообщения * (from_me = 0) которые не прочитаны (read = 0) */ @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( """ 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( """ 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)" ) suspend fun messageExists(account: String, messageId: String): Boolean /** * Отметить все исходящие сообщения к собеседнику как прочитанные Используется когда приходит * PacketRead от собеседника 🔥 ВАЖНО: delivered=3 означает READ (синхронизировано с * ChatViewModel) */ @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( """ 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 * если сообщений нет */ @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 */ @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 interface DialogDao { /** Вставка/обновление диалога */ @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertDialog(dialog: DialogEntity): Long /** * Получить все диалоги отсортированные по последнему сообщению Исключает requests (диалоги без * исходящих сообщений от нас) Исключает пустые диалоги (без сообщений) */ @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 - диалоги где нам писали, но мы не отвечали Исключает пустые диалоги (без * сообщений) */ @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( """ 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( """ 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 updateUnreadCount(account: String, opponentKey: String, count: Int) /** Инкрементировать непрочитанные */ @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" ) suspend fun clearUnreadCount(account: String, opponentKey: String) /** Отметить что я отправлял сообщения в этот диалог Возвращает количество обновлённых строк */ @Query( "UPDATE dialogs SET i_have_sent = 1 WHERE account = :account AND opponent_key = :opponentKey" ) suspend fun markIHaveSent(account: String, opponentKey: String): Int /** Обновить онлайн статус */ @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( """ 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 ) /** Удалить диалог */ @Query("DELETE FROM dialogs WHERE account = :account AND opponent_key = :opponentKey") suspend fun deleteDialog(account: String, opponentKey: String) /** Обновить информацию о собеседнике */ @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 ) /** * Получить общее количество непрочитанных сообщений, исключая указанный диалог Используется для * отображения badge на кнопке "назад" в экране чата */ @Query( """ SELECT COALESCE(SUM(unread_count), 0) FROM dialogs WHERE account = :account AND opponent_key != :excludeOpponentKey """ ) fun getTotalUnreadCountExcludingFlow(account: String, excludeOpponentKey: String): Flow /** 🚀 Получить последнее сообщение диалога по 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 = :opponentKey AND from_me = 1 LIMIT 1 ) """ ) 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 ) ) } }