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 1b9cc55..ebc44d4 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -8,6 +8,7 @@ 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.Locale import java.util.UUID import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -51,6 +52,7 @@ class MessageRepository private constructor(private val context: Context) { private val avatarDao = database.avatarDao() private val syncTimeDao = database.syncTimeDao() private val groupDao = database.groupDao() + private val searchIndexDao = database.messageSearchIndexDao() private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -214,6 +216,25 @@ class MessageRepository private constructor(private val context: Context) { if (inserted == -1L) return + val insertedMessage = + MessageEntity( + account = account, + fromPublicKey = SYSTEM_SAFE_PUBLIC_KEY, + toPublicKey = account, + content = "", + timestamp = timestamp, + chachaKey = "", + read = 0, + fromMe = 0, + delivered = DeliveryStatus.DELIVERED.value, + messageId = messageId, + plainMessage = encryptedPlainMessage, + attachments = "[]", + primaryAttachmentType = -1, + dialogKey = dialogKey + ) + upsertSearchIndex(account, insertedMessage, messageText) + val existing = dialogDao.getDialog(account, SYSTEM_SAFE_PUBLIC_KEY) dialogDao.insertDialog( DialogEntity( @@ -274,6 +295,25 @@ class MessageRepository private constructor(private val context: Context) { if (inserted == -1L) return null + val insertedMessage = + MessageEntity( + account = account, + fromPublicKey = SYSTEM_UPDATES_PUBLIC_KEY, + toPublicKey = account, + content = "", + timestamp = timestamp, + chachaKey = "", + read = 0, + fromMe = 0, + delivered = DeliveryStatus.DELIVERED.value, + messageId = messageId, + plainMessage = encryptedPlainMessage, + attachments = "[]", + primaryAttachmentType = -1, + dialogKey = dialogKey + ) + upsertSearchIndex(account, insertedMessage, messageText) + val existing = dialogDao.getDialog(account, SYSTEM_UPDATES_PUBLIC_KEY) dialogDao.insertDialog( DialogEntity( @@ -536,6 +576,7 @@ class MessageRepository private constructor(private val context: Context) { dialogKey = dialogKey ) messageDao.insertMessage(entity) + upsertSearchIndex(account, entity, text.trim()) // 📝 LOG: Сохранено в БД MessageLogger.logDbSave(messageId, dialogKey, isNew = true) @@ -563,6 +604,17 @@ class MessageRepository private constructor(private val context: Context) { lastSeen = existing?.lastSeen ?: 0, verified = existing?.verified ?: 0, iHaveSent = 1, + hasContent = + if ( + encryptedPlainMessage.isNotBlank() || + attachments.isNotEmpty() + ) { + 1 + } else { + 0 + }, + lastMessageAttachmentType = resolvePrimaryAttachmentType(attachments), + lastSenderKey = account, lastMessageFromMe = 1, lastMessageDelivered = 1, lastMessageRead = 1, @@ -875,6 +927,7 @@ class MessageRepository private constructor(private val context: Context) { if (!stillExists) { // Сохраняем в БД только если сообщения нет messageDao.insertMessage(entity) + upsertSearchIndex(account, entity, plainText) MessageLogger.logDbSave(messageId, dialogKey, isNew = true) } else { MessageLogger.logDbSave(messageId, dialogKey, isNew = false) @@ -1411,7 +1464,8 @@ class MessageRepository private constructor(private val context: Context) { opponentKey = opponentKey, lastMessage = encryptedLastMessage, lastMessageTimestamp = timestamp, - unreadCount = unreadCount + unreadCount = unreadCount, + hasContent = if (encryptedLastMessage.isNotBlank()) 1 else 0 ) ) } @@ -1649,6 +1703,26 @@ class MessageRepository private constructor(private val context: Context) { return attachments.first().type.value } + private suspend fun upsertSearchIndex(account: String, entity: MessageEntity, plainText: String) { + val opponentKey = + if (entity.fromMe == 1) entity.toPublicKey.trim() else entity.fromPublicKey.trim() + val normalized = plainText.lowercase(Locale.ROOT) + searchIndexDao.upsert( + listOf( + MessageSearchIndexEntity( + account = account, + messageId = entity.messageId, + dialogKey = entity.dialogKey, + opponentKey = opponentKey, + timestamp = entity.timestamp, + fromMe = entity.fromMe, + plainText = plainText, + plainTextNormalized = normalized + ) + ) + ) + } + /** * 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш Как в desktop: при * получении attachment с типом AVATAR - сохраняем в avatar_cache 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 f5f490a..13fdf0c 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -82,6 +82,7 @@ data class LastMessageStatus( Index(value = ["account", "from_public_key", "to_public_key", "timestamp"]), Index(value = ["account", "message_id"], unique = true), Index(value = ["account", "dialog_key", "timestamp"]), + Index(value = ["account", "timestamp"]), Index(value = ["account", "primary_attachment_type", "timestamp"])] ) data class MessageEntity( @@ -107,13 +108,40 @@ data class MessageEntity( @ColumnInfo(name = "dialog_key") val dialogKey: String // Ключ диалога для быстрой выборки ) +/** Локальный денормализованный индекс для поиска по сообщениям без повторной дешифровки. */ +@Entity( + tableName = "message_search_index", + primaryKeys = ["account", "message_id"], + indices = + [ + Index(value = ["account", "timestamp"]), + Index(value = ["account", "opponent_key", "timestamp"])] +) +data class MessageSearchIndexEntity( + @ColumnInfo(name = "account") val account: String, + @ColumnInfo(name = "message_id") val messageId: String, + @ColumnInfo(name = "dialog_key") val dialogKey: String, + @ColumnInfo(name = "opponent_key") val opponentKey: String, + @ColumnInfo(name = "timestamp") val timestamp: Long, + @ColumnInfo(name = "from_me") val fromMe: Int = 0, + @ColumnInfo(name = "plain_text") val plainText: String, + @ColumnInfo(name = "plain_text_normalized") val plainTextNormalized: String +) + /** Entity для диалогов (кэш последнего сообщения) */ @Entity( tableName = "dialogs", indices = [ Index(value = ["account", "opponent_key"], unique = true), - Index(value = ["account", "last_message_timestamp"])] + Index(value = ["account", "last_message_timestamp"]), + Index( + value = + [ + "account", + "i_have_sent", + "has_content", + "last_message_timestamp"])] ) data class DialogEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, @@ -132,6 +160,12 @@ data class DialogEntity( @ColumnInfo(name = "verified") val verified: Int = 0, // Верифицирован @ColumnInfo(name = "i_have_sent", defaultValue = "0") val iHaveSent: Int = 0, // Отправлял ли я сообщения в этот диалог (0/1) + @ColumnInfo(name = "has_content", defaultValue = "0") + val hasContent: Int = 0, // Есть ли контент в диалоге (0/1) + @ColumnInfo(name = "last_message_attachment_type", defaultValue = "-1") + val lastMessageAttachmentType: Int = -1, // Денормализованный тип вложения последнего сообщения + @ColumnInfo(name = "last_sender_key", defaultValue = "''") + val lastSenderKey: String = "", // Для групп: публичный ключ последнего отправителя @ColumnInfo(name = "last_message_from_me", defaultValue = "0") val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1) @ColumnInfo(name = "last_message_delivered", defaultValue = "0") @@ -548,10 +582,7 @@ interface MessageDao { """ SELECT * FROM messages WHERE account = :account - AND ( - primary_attachment_type = 0 - OR (primary_attachment_type = -1 AND attachments != '[]' AND attachments LIKE '%"type":0%') - ) + AND primary_attachment_type = 0 ORDER BY timestamp DESC LIMIT :limit OFFSET :offset """ @@ -566,10 +597,7 @@ interface MessageDao { """ SELECT * FROM messages WHERE account = :account - AND ( - primary_attachment_type = 2 - OR (primary_attachment_type = -1 AND attachments != '[]' AND attachments LIKE '%"type":2%') - ) + AND primary_attachment_type = 2 ORDER BY timestamp DESC LIMIT :limit OFFSET :offset """ @@ -600,14 +628,7 @@ interface MessageDao { ELSE m.from_public_key END WHERE m.account = :account - AND ( - m.primary_attachment_type = 4 - OR ( - m.primary_attachment_type = -1 - AND m.attachments != '[]' - AND (m.attachments LIKE '%"type":4%' OR m.attachments LIKE '%"type": 4%') - ) - ) + AND m.primary_attachment_type = 4 ORDER BY m.timestamp DESC, m.message_id DESC LIMIT :limit """ @@ -624,14 +645,7 @@ interface MessageDao { END AS peer_key FROM messages WHERE account = :account - AND ( - primary_attachment_type = 4 - OR ( - primary_attachment_type = -1 - AND attachments != '[]' - AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%') - ) - ) + AND primary_attachment_type = 4 """ ) suspend fun getCallHistoryPeers(account: String): List @@ -641,20 +655,13 @@ interface MessageDao { """ DELETE FROM messages WHERE account = :account - AND ( - primary_attachment_type = 4 - OR ( - primary_attachment_type = -1 - AND attachments != '[]' - AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%') - ) - ) + AND primary_attachment_type = 4 """ ) suspend fun deleteAllCallMessages(account: String): Int /** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */ - @Query( + @Query( """ SELECT * FROM messages WHERE account = :account @@ -662,10 +669,51 @@ interface MessageDao { ORDER BY timestamp DESC LIMIT :limit OFFSET :offset """ - ) + ) suspend fun getAllMessagesPaged(account: String, limit: Int, offset: Int): List } +@Dao +interface MessageSearchIndexDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(items: List) + + @Query( + """ + SELECT * FROM message_search_index + WHERE account = :account + AND plain_text_normalized LIKE '%' || :queryNormalized || '%' + ORDER BY timestamp DESC + LIMIT :limit OFFSET :offset + """ + ) + suspend fun search( + account: String, + queryNormalized: String, + limit: Int, + offset: Int = 0 + ): List + + @Query( + """ + SELECT m.* FROM messages m + LEFT JOIN message_search_index s + ON s.account = m.account + AND s.message_id = m.message_id + WHERE m.account = :account + AND m.plain_message != '' + AND s.message_id IS NULL + ORDER BY m.timestamp DESC + LIMIT :limit + """ + ) + suspend fun getUnindexedMessages(account: String, limit: Int): List + + @Query("DELETE FROM message_search_index WHERE account = :account") + suspend fun deleteByAccount(account: String): Int +} + /** DAO для работы с диалогами */ @Dao interface DialogDao { @@ -687,7 +735,7 @@ interface DialogDao { OR opponent_key = '0x000000000000000000000000000000000000000001' OR opponent_key = '0x000000000000000000000000000000000000000002' ) - AND (last_message != '' OR last_message_attachments != '[]') + AND has_content = 1 ORDER BY last_message_timestamp DESC LIMIT 30 """ @@ -704,7 +752,7 @@ interface DialogDao { OR opponent_key = '0x000000000000000000000000000000000000000001' OR opponent_key = '0x000000000000000000000000000000000000000002' ) - AND (last_message != '' OR last_message_attachments != '[]') + AND has_content = 1 ORDER BY last_message_timestamp DESC LIMIT :limit OFFSET :offset """ @@ -722,7 +770,7 @@ interface DialogDao { AND i_have_sent = 0 AND opponent_key != '0x000000000000000000000000000000000000000001' AND opponent_key != '0x000000000000000000000000000000000000000002' - AND (last_message != '' OR last_message_attachments != '[]') + AND has_content = 1 ORDER BY last_message_timestamp DESC LIMIT 30 """ @@ -737,7 +785,7 @@ interface DialogDao { AND i_have_sent = 0 AND opponent_key != '0x000000000000000000000000000000000000000001' AND opponent_key != '0x000000000000000000000000000000000000000002' - AND (last_message != '' OR last_message_attachments != '[]') + AND has_content = 1 """ ) fun getRequestsCountFlow(account: String): Flow @@ -750,7 +798,7 @@ interface DialogDao { @Query(""" SELECT * FROM dialogs WHERE account = :account - AND (last_message != '' OR last_message_attachments != '[]') + AND has_content = 1 AND opponent_key NOT LIKE '#group:%' AND ( opponent_title = '' @@ -781,7 +829,8 @@ interface DialogDao { """ UPDATE dialogs SET last_message = :lastMessage, - last_message_timestamp = :timestamp + last_message_timestamp = :timestamp, + has_content = CASE WHEN TRIM(:lastMessage) != '' THEN 1 ELSE has_content END WHERE account = :account AND opponent_key = :opponentKey """ ) @@ -1010,6 +1059,16 @@ interface DialogDao { val hasSent = hasSentByDialogKey(account, dialogKey) // 5. Один INSERT OR REPLACE с вычисленными данными + val hasContent = + if ( + lastMsg.plainMessage.isNotBlank() || + (lastMsg.attachments.isNotBlank() && + lastMsg.attachments.trim() != "[]") + ) { + 1 + } else { + 0 + } insertDialog( DialogEntity( id = existing?.id ?: 0, @@ -1025,6 +1084,9 @@ interface DialogDao { verified = existing?.verified ?: 0, // Desktop parity: request flag is always derived from message history. iHaveSent = if (hasSent || isSystemDialog) 1 else 0, + hasContent = hasContent, + lastMessageAttachmentType = lastMsg.primaryAttachmentType, + lastSenderKey = lastMsg.fromPublicKey, lastMessageFromMe = lastMsg.fromMe, lastMessageDelivered = if (lastMsg.fromMe == 1) lastMsg.delivered else 0, lastMessageRead = if (lastMsg.fromMe == 1) lastMsg.read else 0, @@ -1044,6 +1106,16 @@ interface DialogDao { val lastMsg = getLastMessageByDialogKey(account, dialogKey) ?: return val existing = getDialog(account, account) + val hasContent = + if ( + lastMsg.plainMessage.isNotBlank() || + (lastMsg.attachments.isNotBlank() && + lastMsg.attachments.trim() != "[]") + ) { + 1 + } else { + 0 + } insertDialog( DialogEntity( @@ -1059,6 +1131,9 @@ interface DialogDao { lastSeen = existing?.lastSeen ?: 0, verified = existing?.verified ?: 0, iHaveSent = 1, + hasContent = hasContent, + lastMessageAttachmentType = lastMsg.primaryAttachmentType, + lastSenderKey = lastMsg.fromPublicKey, lastMessageFromMe = 1, lastMessageDelivered = 1, lastMessageRead = 1, 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 c4d71ed..866f0ff 100644 --- a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt +++ b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt @@ -12,13 +12,14 @@ import androidx.sqlite.db.SupportSQLiteDatabase [ EncryptedAccountEntity::class, MessageEntity::class, + MessageSearchIndexEntity::class, DialogEntity::class, BlacklistEntity::class, AvatarCacheEntity::class, AccountSyncTimeEntity::class, GroupEntity::class, PinnedMessageEntity::class], - version = 15, + version = 17, exportSchema = false ) abstract class RosettaDatabase : RoomDatabase() { @@ -30,6 +31,7 @@ abstract class RosettaDatabase : RoomDatabase() { abstract fun syncTimeDao(): SyncTimeDao abstract fun groupDao(): GroupDao abstract fun pinnedMessageDao(): PinnedMessageDao + abstract fun messageSearchIndexDao(): MessageSearchIndexDao companion object { @Volatile private var INSTANCE: RosettaDatabase? = null @@ -232,6 +234,124 @@ abstract class RosettaDatabase : RoomDatabase() { } } + /** + * 🧱 МИГРАЦИЯ 15->16: Денормализованный has_content для быстрых выборок dialogs/requests + */ + private val MIGRATION_15_16 = + object : Migration(15, 16) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "ALTER TABLE dialogs ADD COLUMN has_content INTEGER NOT NULL DEFAULT 0" + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS index_dialogs_account_i_have_sent_has_content_last_message_timestamp ON dialogs (account, i_have_sent, has_content, last_message_timestamp)" + ) + database.execSQL( + """ + UPDATE dialogs + SET has_content = CASE + WHEN TRIM(last_message) != '' THEN 1 + WHEN last_message_attachments IS NOT NULL + AND TRIM(last_message_attachments) != '' + AND TRIM(last_message_attachments) != '[]' THEN 1 + ELSE 0 + END + """ + ) + } + } + + /** + * 🧱 МИГРАЦИЯ 16->17: + * - dialogs: last_message_attachment_type + last_sender_key + * - messages: индекс (account, timestamp) + * - локальный message_search_index для поиска без повторной дешифровки + */ + private val MIGRATION_16_17 = + object : Migration(16, 17) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "ALTER TABLE dialogs ADD COLUMN last_message_attachment_type INTEGER NOT NULL DEFAULT -1" + ) + database.execSQL( + "ALTER TABLE dialogs ADD COLUMN last_sender_key TEXT NOT NULL DEFAULT ''" + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS index_messages_account_timestamp ON messages (account, timestamp)" + ) + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS message_search_index ( + account TEXT NOT NULL, + message_id TEXT NOT NULL, + dialog_key TEXT NOT NULL, + opponent_key TEXT NOT NULL, + timestamp INTEGER NOT NULL, + from_me INTEGER NOT NULL DEFAULT 0, + plain_text TEXT NOT NULL, + plain_text_normalized TEXT NOT NULL, + PRIMARY KEY(account, message_id) + ) + """ + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS index_message_search_index_account_timestamp ON message_search_index (account, timestamp)" + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS index_message_search_index_account_opponent_key_timestamp ON message_search_index (account, opponent_key, timestamp)" + ) + database.execSQL( + """ + UPDATE dialogs + SET last_message_attachment_type = CASE + WHEN last_message_attachments IS NULL + OR TRIM(last_message_attachments) = '' + OR TRIM(last_message_attachments) = '[]' THEN -1 + WHEN last_message_attachments LIKE '%"type":0%' OR last_message_attachments LIKE '%"type": 0%' THEN 0 + WHEN last_message_attachments LIKE '%"type":1%' OR last_message_attachments LIKE '%"type": 1%' THEN 1 + WHEN last_message_attachments LIKE '%"type":2%' OR last_message_attachments LIKE '%"type": 2%' THEN 2 + WHEN last_message_attachments LIKE '%"type":3%' OR last_message_attachments LIKE '%"type": 3%' THEN 3 + WHEN last_message_attachments LIKE '%"type":4%' OR last_message_attachments LIKE '%"type": 4%' THEN 4 + ELSE -1 + END + """ + ) + database.execSQL( + """ + UPDATE dialogs + SET last_sender_key = COALESCE( + ( + SELECT m.from_public_key + FROM messages m + WHERE m.account = dialogs.account + AND m.dialog_key = CASE + WHEN dialogs.opponent_key = dialogs.account THEN dialogs.account + WHEN LOWER(dialogs.opponent_key) LIKE '#group:%' OR LOWER(dialogs.opponent_key) LIKE 'group:%' + THEN dialogs.opponent_key + WHEN dialogs.account < dialogs.opponent_key + THEN dialogs.account || ':' || dialogs.opponent_key + ELSE dialogs.opponent_key || ':' || dialogs.account + END + ORDER BY m.timestamp DESC, m.message_id DESC + LIMIT 1 + ), + '' + ) + """ + ) + database.execSQL( + """ + CREATE TRIGGER IF NOT EXISTS trg_message_search_index_delete + AFTER DELETE ON messages + BEGIN + DELETE FROM message_search_index + WHERE account = OLD.account AND message_id = OLD.message_id; + END + """ + ) + } + } + fun getDatabase(context: Context): RosettaDatabase { return INSTANCE ?: synchronized(this) { @@ -255,7 +375,9 @@ abstract class RosettaDatabase : RoomDatabase() { MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, - MIGRATION_14_15 + MIGRATION_14_15, + MIGRATION_15_16, + MIGRATION_16_17 ) .fallbackToDestructiveMigration() // Для разработки - только // если миграция не diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 07f1e53..d969a3a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -98,6 +98,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private val database = RosettaDatabase.getDatabase(application) private val dialogDao = database.dialogDao() private val messageDao = database.messageDao() + private val searchIndexDao = database.messageSearchIndexDao() private val groupDao = database.groupDao() private val pinnedMessageDao = database.pinnedMessageDao() @@ -126,6 +127,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // UI State private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages.asStateFlow() + @Volatile private var normalizedMessagesDescCache: List = emptyList() /** * Pre-computed messages with date headers — runs dedup + sort on Dispatchers.Default. Replaces @@ -143,20 +145,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { .debounce(16) // coalesce rapid updates (1 frame) .mapLatest { rawMessages -> withContext(Dispatchers.Default) { - val unique = rawMessages.distinctBy { it.id } - val sorted = unique.sortedWith(chatMessageDescComparator) - val result = ArrayList>(sorted.size) - var prevDateStr: String? = null - for (i in sorted.indices) { - val msg = sorted[i] - val dateStr = _dateFmt.format(msg.timestamp) - val nextMsg = sorted.getOrNull(i + 1) - val nextDateStr = nextMsg?.let { _dateFmt.format(it.timestamp) } - val showDate = nextDateStr == null || nextDateStr != dateStr - result.add(msg to showDate) - prevDateStr = dateStr - } - result as List> + val normalized = + normalizeMessagesDescendingIncremental( + previous = normalizedMessagesDescCache, + incoming = rawMessages + ) + normalizedMessagesDescCache = normalized + buildMessagesWithDateHeaders(normalized) } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) @@ -234,6 +229,98 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private fun sortMessagesAscending(messages: List): List = messages.sortedWith(chatMessageAscComparator) + private fun isSortedDescending(messages: List): Boolean { + if (messages.size < 2) return true + for (i in 0 until messages.lastIndex) { + if (chatMessageDescComparator.compare(messages[i], messages[i + 1]) > 0) { + return false + } + } + return true + } + + private fun insertIntoSortedDescending( + existing: List, + message: ChatMessage + ): List { + if (existing.isEmpty()) return listOf(message) + val result = ArrayList(existing.size + 1) + var inserted = false + existing.forEach { current -> + if (!inserted && chatMessageDescComparator.compare(message, current) <= 0) { + result.add(message) + inserted = true + } + result.add(current) + } + if (!inserted) result.add(message) + return result + } + + private fun normalizeMessagesDescendingIncremental( + previous: List, + incoming: List + ): List { + if (incoming.isEmpty()) return emptyList() + + val dedupedById = LinkedHashMap(incoming.size) + incoming.forEach { message -> dedupedById[message.id] = message } + + if (previous.isNotEmpty() && dedupedById.size == previous.size) { + var unchanged = true + previous.forEach { message -> + if (dedupedById[message.id] != message) { + unchanged = false + return@forEach + } + } + if (unchanged && isSortedDescending(previous)) { + return previous + } + } + + if (previous.isNotEmpty() && dedupedById.size == previous.size + 1) { + val previousIds = HashSet(previous.size) + previous.forEach { previousIds.add(it.id) } + + val addedIds = dedupedById.keys.filter { it !in previousIds } + if (addedIds.size == 1) { + var previousUnchanged = true + previous.forEach { message -> + if (dedupedById[message.id] != message) { + previousUnchanged = false + return@forEach + } + } + if (previousUnchanged) { + val addedMessage = dedupedById.getValue(addedIds.first()) + return insertIntoSortedDescending(previous, addedMessage) + } + } + } + + val normalized = ArrayList(dedupedById.values) + if (!isSortedDescending(normalized)) { + normalized.sortWith(chatMessageDescComparator) + } + return normalized + } + + private fun buildMessagesWithDateHeaders( + sortedMessagesDesc: List + ): List> { + val result = ArrayList>(sortedMessagesDesc.size) + for (i in sortedMessagesDesc.indices) { + val msg = sortedMessagesDesc[i] + val dateStr = _dateFmt.format(msg.timestamp) + val nextMsg = sortedMessagesDesc.getOrNull(i + 1) + val nextDateStr = nextMsg?.let { _dateFmt.format(it.timestamp) } + val showDate = nextDateStr == null || nextDateStr != dateStr + result.add(msg to showDate) + } + return result + } + private fun latestIncomingMessage(messages: List): ChatMessage? = messages.asSequence().filter { !it.isOutgoing }.maxWithOrNull(chatMessageAscComparator) @@ -4648,6 +4735,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ) val insertedId = messageDao.insertMessage(entity) + searchIndexDao.upsert( + listOf( + com.rosetta.messenger.database.MessageSearchIndexEntity( + account = account, + messageId = finalMessageId, + dialogKey = dialogKey, + opponentKey = opponent.trim(), + timestamp = timestamp, + fromMe = if (isFromMe) 1 else 0, + plainText = text, + plainTextNormalized = text.lowercase(Locale.ROOT) + ) + ) + ) } catch (e: Exception) {} } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 3ed62a1..f6e89ac 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -286,9 +286,6 @@ fun ChatsListScreen( val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() val sduUpdateState by UpdateManager.updateState.collectAsState() - val sduDownloadProgress by UpdateManager.downloadProgress.collectAsState() - val sduDebugLogs by UpdateManager.debugLogs.collectAsState() - var showSduLogs by remember { mutableStateOf(false) } val themeRevealRadius = remember { Animatable(0f) } var rootSize by remember { mutableStateOf(IntSize.Zero) } var themeToggleCenterInRoot by remember { mutableStateOf(null) } @@ -297,73 +294,6 @@ fun ChatsListScreen( var themeRevealCenter by remember { mutableStateOf(Offset.Zero) } var themeRevealSnapshot by remember { mutableStateOf(null) } - // ═══════════════ SDU Debug Log Dialog ═══════════════ - if (showSduLogs) { - AlertDialog( - onDismissRequest = { showSduLogs = false }, - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("SDU Logs", fontWeight = FontWeight.Bold, fontSize = 16.sp) - Spacer(modifier = Modifier.weight(1f)) - Text( - "state: ${sduUpdateState::class.simpleName}", - fontSize = 11.sp, - color = Color.Gray - ) - } - }, - text = { - val scrollState = rememberScrollState() - LaunchedEffect(sduDebugLogs.size) { - scrollState.animateScrollTo(scrollState.maxValue) - } - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 400.dp) - .verticalScroll(scrollState) - ) { - if (sduDebugLogs.isEmpty()) { - Text( - "Нет логов. SDU ещё не инициализирован\nили пакет 0x0A не пришёл.", - fontSize = 13.sp, - color = Color.Gray - ) - } else { - sduDebugLogs.forEach { line -> - Text( - text = line, - fontSize = 11.sp, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - color = when { - "ERROR" in line || "EXCEPTION" in line -> Color(0xFFFF5555) - "WARNING" in line -> Color(0xFFFFAA33) - "State ->" in line -> Color(0xFF55BB55) - else -> if (isDarkTheme) Color(0xFFCCCCCC) else Color(0xFF333333) - }, - modifier = Modifier.padding(vertical = 1.dp) - ) - } - } - } - }, - confirmButton = { - Row { - TextButton(onClick = { - // Retry: force re-request SDU - UpdateManager.requestSduServer() - }) { - Text("Retry SDU") - } - Spacer(modifier = Modifier.width(8.dp)) - TextButton(onClick = { showSduLogs = false }) { - Text("Close") - } - } - } - ) - } - fun startThemeReveal() { if (themeRevealActive) { return @@ -2167,27 +2097,27 @@ fun ChatsListScreen( } // 🔥 Берем dialogs из chatsState для // консистентности - // 📌 Сортируем: pinned сначала, потом по - // времени + // 📌 Порядок по времени готовится в ViewModel. + // Здесь поднимаем pinned наверх без полного sort/distinct. val currentDialogs = remember( chatsState.dialogs, pinnedChats ) { - chatsState.dialogs - .distinctBy { it.opponentKey } - .sortedWith( - compareByDescending< - DialogUiModel> { - pinnedChats - .contains( - it.opponentKey - ) - } - .thenByDescending { - it.lastMessageTimestamp - } - ) + val pinned = ArrayList() + val regular = ArrayList() + chatsState.dialogs.forEach { dialog -> + if ( + pinnedChats.contains( + dialog.opponentKey + ) + ) { + pinned.add(dialog) + } else { + regular.add(dialog) + } + } + pinned + regular } // Telegram-style: only one item can be 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 744a674..1377898 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 @@ -17,8 +17,6 @@ import com.rosetta.messenger.network.SearchUser import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -80,6 +78,14 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio // 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл private val subscribedOnlineKeys = mutableSetOf() + private data class DialogUiCacheEntry( + val signature: Int, + val model: DialogUiModel + ) + + private val dialogsUiCache = LinkedHashMap() + private val requestsUiCache = LinkedHashMap() + // Job для отмены подписок при смене аккаунта private var accountSubscriptionsJob: Job? = null @@ -147,19 +153,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio if (!isGroupKey(dialog.opponentKey) || dialog.lastMessageTimestamp <= 0L) return null val senderKey = - if (dialog.lastMessageFromMe == 1) { - currentAccount - } else { - val lastMessage = - try { - dialogDao.getLastMessageByDialogKey( - account = dialog.account, - dialogKey = dialog.opponentKey.trim() - ) - } catch (_: Exception) { - null - } - lastMessage?.fromPublicKey.orEmpty() + dialog.lastSenderKey.trim().ifBlank { + if (dialog.lastMessageFromMe == 1) currentAccount else "" } if (senderKey.isBlank()) return null @@ -226,6 +221,123 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio return null } + private fun buildDialogSignature(dialog: com.rosetta.messenger.database.DialogEntity): Int { + var result = dialog.id.hashCode() + result = 31 * result + dialog.account.hashCode() + result = 31 * result + dialog.opponentKey.hashCode() + result = 31 * result + dialog.opponentTitle.hashCode() + result = 31 * result + dialog.opponentUsername.hashCode() + result = 31 * result + dialog.lastMessage.hashCode() + result = 31 * result + dialog.lastMessageTimestamp.hashCode() + result = 31 * result + dialog.unreadCount + result = 31 * result + dialog.isOnline + result = 31 * result + dialog.lastSeen.hashCode() + result = 31 * result + dialog.verified + result = 31 * result + dialog.lastMessageFromMe + result = 31 * result + dialog.lastMessageDelivered + result = 31 * result + dialog.lastMessageRead + result = 31 * result + dialog.lastMessageAttachments.hashCode() + return result + } + + private fun shouldRequestDialogUserInfo( + dialog: com.rosetta.messenger.database.DialogEntity, + isRequestsFlow: Boolean + ): Boolean { + val title = dialog.opponentTitle + if (isRequestsFlow) { + return title.isEmpty() || title == dialog.opponentKey + } + return title.isEmpty() || + title == dialog.opponentKey || + title == dialog.opponentKey.take(7) || + title == dialog.opponentKey.take(8) + } + + private fun normalizeDialogList(dialogs: List): List { + if (dialogs.isEmpty()) return emptyList() + val deduped = LinkedHashMap(dialogs.size) + dialogs.forEach { dialog -> + if (!deduped.containsKey(dialog.opponentKey)) { + deduped[dialog.opponentKey] = dialog + } + } + return deduped.values.sortedByDescending { it.lastMessageTimestamp } + } + + private suspend fun mapDialogListIncremental( + dialogsList: List, + privateKey: String, + cache: LinkedHashMap, + isRequestsFlow: Boolean + ): List { + return withContext(Dispatchers.Default) { + val activeKeys = HashSet(dialogsList.size) + val mapped = ArrayList(dialogsList.size) + + dialogsList.forEach { dialog -> + val cacheKey = dialog.opponentKey + activeKeys.add(cacheKey) + + val signature = buildDialogSignature(dialog) + val cached = cache[cacheKey] + if (cached != null && cached.signature == signature) { + mapped.add(cached.model) + return@forEach + } + + val isSavedMessages = dialog.account == dialog.opponentKey + if (!isSavedMessages && shouldRequestDialogUserInfo(dialog, isRequestsFlow)) { + if (isRequestsFlow) { + loadUserInfoForRequest(dialog.opponentKey) + } else { + loadUserInfoForDialog(dialog.opponentKey) + } + } + + val decryptedLastMessage = + decryptDialogPreview( + encryptedLastMessage = dialog.lastMessage, + privateKey = privateKey + ) + val attachmentType = + resolveAttachmentType( + attachmentType = dialog.lastMessageAttachmentType, + decryptedLastMessage = decryptedLastMessage + ) + val groupLastSenderInfo = resolveGroupLastSenderInfo(dialog) + + val model = + 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, + lastMessageFromMe = dialog.lastMessageFromMe, + lastMessageDelivered = dialog.lastMessageDelivered, + lastMessageRead = dialog.lastMessageRead, + lastMessageAttachmentType = attachmentType, + lastMessageSenderPrefix = groupLastSenderInfo?.senderPrefix, + lastMessageSenderKey = groupLastSenderInfo?.senderKey + ) + + cache[cacheKey] = DialogUiCacheEntry(signature = signature, model = model) + mapped.add(model) + } + + cache.keys.retainAll(activeKeys) + normalizeDialogList(mapped) + } + } + /** Установить текущий аккаунт и загрузить диалоги */ fun setAccount(publicKey: String, privateKey: String) { val setAccountStart = System.currentTimeMillis() @@ -241,6 +353,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio // 🔥 Очищаем кэш запрошенных user info при смене аккаунта requestedUserInfoKeys.clear() subscribedOnlineKeys.clear() + dialogsUiCache.clear() + requestsUiCache.clear() // 🔥 Очищаем глобальный кэш сообщений ChatViewModel при смене аккаунта // чтобы избежать показа сообщений с неправильным isOutgoing @@ -280,104 +394,16 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio .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 - ) || - dialog.opponentTitle == - dialog.opponentKey.take( - 8 - )) - ) { - loadUserInfoForDialog(dialog.opponentKey) - } - - // Безопасная дешифровка превью: никогда не показываем raw ciphertext. - val decryptedLastMessage = - decryptDialogPreview( - encryptedLastMessage = - dialog.lastMessage, - privateKey = privateKey - ) - - // � ОПТИМИЗАЦИЯ: Используем денормализованные поля из - // DialogEntity - // Статус и attachments уже записаны в dialogs через - // updateDialogFromMessages() - // Это устраняет N+1 проблему (ранее: 2 запроса на - // каждый диалог) - - // 📎 Определяем тип attachment из кэшированного поля в - // DialogEntity - val attachmentType = - resolveAttachmentType( - attachmentsJson = - dialog.lastMessageAttachments, - decryptedLastMessage = - decryptedLastMessage - ) - val groupLastSenderInfo = - resolveGroupLastSenderInfo(dialog) - - 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 - lastMessageSenderPrefix = - groupLastSenderInfo?.senderPrefix, - lastMessageSenderKey = - groupLastSenderInfo?.senderKey - ) - } - } - .awaitAll() - } - .also { - val mapTime = System.currentTimeMillis() - mapStart - } + mapDialogListIncremental( + dialogsList = dialogsList, + privateKey = privateKey, + cache = dialogsUiCache, + isRequestsFlow = false + ) } .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .collect { decryptedDialogs -> - // Deduplicate by opponentKey to prevent LazyColumn crash - // (Key "X" was already used) - _dialogs.value = decryptedDialogs.distinctBy { it.opponentKey } + _dialogs.value = decryptedDialogs // 🚀 Убираем skeleton после первой загрузки if (_isLoading.value) _isLoading.value = false @@ -400,83 +426,15 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio .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) - } - - // Безопасная дешифровка превью: никогда не показываем raw ciphertext. - val decryptedLastMessage = - decryptDialogPreview( - encryptedLastMessage = - dialog.lastMessage, - privateKey = privateKey - ) - - // 📎 Определяем тип attachment из кэшированного поля в - // DialogEntity - val attachmentType = - resolveAttachmentType( - attachmentsJson = - dialog.lastMessageAttachments, - decryptedLastMessage = - decryptedLastMessage - ) - val groupLastSenderInfo = - resolveGroupLastSenderInfo(dialog) - - 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 - lastMessageSenderPrefix = - groupLastSenderInfo?.senderPrefix, - lastMessageSenderKey = - groupLastSenderInfo?.senderKey - ) - } - } - .awaitAll() - } + mapDialogListIncremental( + dialogsList = requestsList, + privateKey = privateKey, + cache = requestsUiCache, + isRequestsFlow = true + ) } .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки - .collect { decryptedRequests -> - _requests.value = decryptedRequests.distinctBy { it.opponentKey } - } + .collect { decryptedRequests -> _requests.value = decryptedRequests } } // 📊 Подписываемся на количество requests @@ -584,38 +542,20 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } private fun resolveAttachmentType( - attachmentsJson: String, + attachmentType: Int, decryptedLastMessage: String ): String? { - if (attachmentsJson.isEmpty() || attachmentsJson == "[]") return null - - return try { - val attachments = org.json.JSONArray(attachmentsJson) - if (attachments.length() == 0) return null - - val firstAttachment = attachments.getJSONObject(0) - when (firstAttachment.optInt("type", -1)) { - 0 -> "Photo" // AttachmentType.IMAGE = 0 - 1 -> { - // AttachmentType.MESSAGES = 1 (Reply/Forward). - // Если текст пустой — показываем "Forwarded" как в desktop. - if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" - } - 2 -> "File" // AttachmentType.FILE = 2 - 3 -> "Avatar" // AttachmentType.AVATAR = 3 - 4 -> "Call" // AttachmentType.CALL = 4 - else -> null - } - } catch (e: Exception) { - // Fallback: если JSON поврежден, но видно MESSAGES attachment и текста нет — это forward. - val hasMessagesType = - attachmentsJson.contains("\"type\":1") || - attachmentsJson.contains("\"type\": 1") - if (decryptedLastMessage.isEmpty() && hasMessagesType) { - "Forwarded" - } else { - null + return when (attachmentType) { + 0 -> "Photo" // AttachmentType.IMAGE = 0 + 1 -> { + // AttachmentType.MESSAGES = 1 (Reply/Forward). + // Если текст пустой — показываем "Forwarded" как в desktop. + if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" } + 2 -> "File" // AttachmentType.FILE = 2 + 3 -> "Avatar" // AttachmentType.AVATAR = 3 + 4 -> "Call" // AttachmentType.CALL = 4 + else -> null } } @@ -701,6 +641,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio _requests.value = _requests.value.filter { it.opponentKey != opponentKey } // 🔥 Обновляем счетчик requests _requestsCount.value = _requests.value.size + dialogsUiCache.remove(opponentKey) + requestsUiCache.remove(opponentKey) // Вычисляем правильный dialog_key val dialogKey = @@ -760,6 +702,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio _dialogs.value = _dialogs.value.filter { it.opponentKey != groupPublicKey } _requests.value = _requests.value.filter { it.opponentKey != groupPublicKey } _requestsCount.value = _requests.value.size + dialogsUiCache.remove(groupPublicKey) + requestsUiCache.remove(groupPublicKey) MessageRepository.getInstance(getApplication()).clearDialogCache(groupPublicKey) ChatViewModel.clearCacheForOpponent(groupPublicKey) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index ea756cf..ced4e60 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -45,7 +45,7 @@ import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.database.RosettaDatabase -import com.rosetta.messenger.database.MessageEntity +import com.rosetta.messenger.database.MessageSearchIndexEntity import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.MessageAttachment import androidx.compose.ui.graphics.asImageBitmap @@ -63,9 +63,6 @@ import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.json.JSONArray @@ -1005,9 +1002,10 @@ private fun MessagesTabContent( } // Persistent decryption cache: messageId → plaintext (survives re-queries) - val decryptCache = remember { ConcurrentHashMap(512) } + val decryptCache = remember(currentUserPublicKey) { ConcurrentHashMap(512) } // Cache for dialog metadata: opponentKey → (title, username, verified) - val dialogCache = remember { ConcurrentHashMap>() } + val dialogCache = + remember(currentUserPublicKey) { ConcurrentHashMap>() } val dateFormat = remember { SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()) } @@ -1044,67 +1042,66 @@ private fun MessagesTabContent( } val queryLower = searchQuery.trim().lowercase() - val matched = mutableListOf() - val semaphore = Semaphore(4) - val batchSize = 200 - var offset = 0 - val maxMessages = 5000 // Safety cap val maxResults = 50 // Don't return more than 50 matches + val batchSize = 200 + val indexDao = db.messageSearchIndexDao() - while (offset < maxMessages && matched.size < maxResults) { - val batch = db.messageDao().getAllMessagesPaged( - currentUserPublicKey, batchSize, offset + fun toUiResult(item: MessageSearchIndexEntity): MessageSearchResult { + val normalized = item.opponentKey.trim() + val meta = dialogCache[normalized] + return MessageSearchResult( + messageId = item.messageId, + dialogKey = item.dialogKey, + opponentKey = normalized, + opponentTitle = meta?.first.orEmpty(), + opponentUsername = meta?.second.orEmpty(), + plainText = item.plainText, + timestamp = item.timestamp, + fromMe = item.fromMe == 1, + verified = meta?.third ?: 0 ) - if (batch.isEmpty()) break - - // Decrypt in parallel, filter by query - val batchResults = kotlinx.coroutines.coroutineScope { - batch.chunked(20).flatMap { chunk -> - chunk.map { msg -> - async { - semaphore.withPermit { - val cached = decryptCache[msg.messageId] - val plain = if (cached != null) { - cached - } else { - val decrypted = try { - CryptoManager.decryptWithPassword( - msg.plainMessage, privateKey - ) - } catch (_: Exception) { null } - if (!decrypted.isNullOrBlank()) { - decryptCache[msg.messageId] = decrypted - } - decrypted - } - - if (!plain.isNullOrBlank() && plain.lowercase().contains(queryLower)) { - val opponent = if (msg.fromMe == 1) msg.toPublicKey else msg.fromPublicKey - val normalized = opponent.trim() - val meta = dialogCache[normalized] - MessageSearchResult( - messageId = msg.messageId, - dialogKey = msg.dialogKey, - opponentKey = normalized, - opponentTitle = meta?.first.orEmpty(), - opponentUsername = meta?.second.orEmpty(), - plainText = plain, - timestamp = msg.timestamp, - fromMe = msg.fromMe == 1, - verified = meta?.third ?: 0 - ) - } else null - } - } - }.awaitAll().filterNotNull() - } - } - - matched.addAll(batchResults) - offset += batchSize } - results = matched.take(maxResults) + var indexed = indexDao.search(currentUserPublicKey, queryLower, maxResults, 0) + var indexingPasses = 0 + while (indexed.size < maxResults && indexingPasses < 15) { + val unindexed = indexDao.getUnindexedMessages(currentUserPublicKey, batchSize) + if (unindexed.isEmpty()) break + + val rows = ArrayList(unindexed.size) + unindexed.forEach { msg -> + val plain = + decryptCache[msg.messageId] + ?: try { + CryptoManager.decryptWithPassword(msg.plainMessage, privateKey) + } catch (_: Exception) { + null + }.orEmpty() + if (plain.isNotEmpty()) { + decryptCache[msg.messageId] = plain + } + val opponent = if (msg.fromMe == 1) msg.toPublicKey else msg.fromPublicKey + rows.add( + MessageSearchIndexEntity( + account = currentUserPublicKey, + messageId = msg.messageId, + dialogKey = msg.dialogKey, + opponentKey = opponent.trim(), + timestamp = msg.timestamp, + fromMe = msg.fromMe, + plainText = plain, + plainTextNormalized = plain.lowercase() + ) + ) + } + if (rows.isNotEmpty()) { + indexDao.upsert(rows) + } + indexingPasses++ + indexed = indexDao.search(currentUserPublicKey, queryLower, maxResults, 0) + } + + results = indexed.map(::toUiResult) } catch (_: Exception) { results = emptyList() }