package com.rosetta.messenger.database import androidx.room.* import kotlinx.coroutines.flow.Flow /** * 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) ) /** * 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 /** * Получить последние 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) /** * Обновить статус прочтения */ @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 /** * Отметить все исходящие сообщения к собеседнику как прочитанные (delivered=3) * Используется когда приходит PacketRead от собеседника */ @Query(""" UPDATE messages SET delivered = 3 WHERE account = :account AND to_public_key = :opponent AND from_me = 1 AND delivered < 3 """) suspend fun markAllAsRead(account: String, opponent: 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("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 /** * Обновить диалог, пересчитав счетчики из таблицы 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 ) 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 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 ) 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 WHERE EXISTS ( SELECT 1 FROM messages WHERE account = :account AND from_public_key = :account AND to_public_key = :account ) """) suspend fun updateSavedMessagesDialogFromMessages(account: String) }