Оптимизация

This commit is contained in:
2026-03-27 23:10:13 +05:00
parent 84aad5f094
commit 3eac17d9a8
7 changed files with 663 additions and 420 deletions

View File

@@ -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

View File

@@ -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<String>
@@ -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<MessageEntity>
}
@Dao
interface MessageSearchIndexDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(items: List<MessageSearchIndexEntity>)
@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<MessageSearchIndexEntity>
@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<MessageEntity>
@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<Int>
@@ -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,

View File

@@ -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() // Для разработки - только
// если миграция не

View File

@@ -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<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
@Volatile private var normalizedMessagesDescCache: List<ChatMessage> = 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<Pair<ChatMessage, Boolean>>(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<Pair<ChatMessage, Boolean>>
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<ChatMessage>): List<ChatMessage> =
messages.sortedWith(chatMessageAscComparator)
private fun isSortedDescending(messages: List<ChatMessage>): 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<ChatMessage>,
message: ChatMessage
): List<ChatMessage> {
if (existing.isEmpty()) return listOf(message)
val result = ArrayList<ChatMessage>(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<ChatMessage>,
incoming: List<ChatMessage>
): List<ChatMessage> {
if (incoming.isEmpty()) return emptyList()
val dedupedById = LinkedHashMap<String, ChatMessage>(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<String>(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<ChatMessage>(dedupedById.values)
if (!isSortedDescending(normalized)) {
normalized.sortWith(chatMessageDescComparator)
}
return normalized
}
private fun buildMessagesWithDateHeaders(
sortedMessagesDesc: List<ChatMessage>
): List<Pair<ChatMessage, Boolean>> {
val result = ArrayList<Pair<ChatMessage, Boolean>>(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>): 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) {}
}

View File

@@ -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<Offset?>(null) }
@@ -297,73 +294,6 @@ fun ChatsListScreen(
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(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<DialogUiModel>()
val regular = ArrayList<DialogUiModel>()
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

View File

@@ -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<String>()
private data class DialogUiCacheEntry(
val signature: Int,
val model: DialogUiModel
)
private val dialogsUiCache = LinkedHashMap<String, DialogUiCacheEntry>()
private val requestsUiCache = LinkedHashMap<String, DialogUiCacheEntry>()
// 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<DialogUiModel>): List<DialogUiModel> {
if (dialogs.isEmpty()) return emptyList()
val deduped = LinkedHashMap<String, DialogUiModel>(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<com.rosetta.messenger.database.DialogEntity>,
privateKey: String,
cache: LinkedHashMap<String, DialogUiCacheEntry>,
isRequestsFlow: Boolean
): List<DialogUiModel> {
return withContext(Dispatchers.Default) {
val activeKeys = HashSet<String>(dialogsList.size)
val mapped = ArrayList<DialogUiModel>(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()
// <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений
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
)
// <20> ОПТИМИЗАЦИЯ: Используем денормализованные поля из
// 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)
}

View File

@@ -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<String, String>(512) }
val decryptCache = remember(currentUserPublicKey) { ConcurrentHashMap<String, String>(512) }
// Cache for dialog metadata: opponentKey → (title, username, verified)
val dialogCache = remember { ConcurrentHashMap<String, Triple<String, String, Int>>() }
val dialogCache =
remember(currentUserPublicKey) { ConcurrentHashMap<String, Triple<String, String, Int>>() }
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<MessageSearchResult>()
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<MessageSearchIndexEntity>(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()
}