Оптимизация

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.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.MessageLogger import com.rosetta.messenger.utils.MessageLogger
import java.util.Locale
import java.util.UUID import java.util.UUID
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -51,6 +52,7 @@ class MessageRepository private constructor(private val context: Context) {
private val avatarDao = database.avatarDao() private val avatarDao = database.avatarDao()
private val syncTimeDao = database.syncTimeDao() private val syncTimeDao = database.syncTimeDao()
private val groupDao = database.groupDao() private val groupDao = database.groupDao()
private val searchIndexDao = database.messageSearchIndexDao()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -214,6 +216,25 @@ class MessageRepository private constructor(private val context: Context) {
if (inserted == -1L) return 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) val existing = dialogDao.getDialog(account, SYSTEM_SAFE_PUBLIC_KEY)
dialogDao.insertDialog( dialogDao.insertDialog(
DialogEntity( DialogEntity(
@@ -274,6 +295,25 @@ class MessageRepository private constructor(private val context: Context) {
if (inserted == -1L) return null 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) val existing = dialogDao.getDialog(account, SYSTEM_UPDATES_PUBLIC_KEY)
dialogDao.insertDialog( dialogDao.insertDialog(
DialogEntity( DialogEntity(
@@ -536,6 +576,7 @@ class MessageRepository private constructor(private val context: Context) {
dialogKey = dialogKey dialogKey = dialogKey
) )
messageDao.insertMessage(entity) messageDao.insertMessage(entity)
upsertSearchIndex(account, entity, text.trim())
// 📝 LOG: Сохранено в БД // 📝 LOG: Сохранено в БД
MessageLogger.logDbSave(messageId, dialogKey, isNew = true) MessageLogger.logDbSave(messageId, dialogKey, isNew = true)
@@ -563,6 +604,17 @@ class MessageRepository private constructor(private val context: Context) {
lastSeen = existing?.lastSeen ?: 0, lastSeen = existing?.lastSeen ?: 0,
verified = existing?.verified ?: 0, verified = existing?.verified ?: 0,
iHaveSent = 1, iHaveSent = 1,
hasContent =
if (
encryptedPlainMessage.isNotBlank() ||
attachments.isNotEmpty()
) {
1
} else {
0
},
lastMessageAttachmentType = resolvePrimaryAttachmentType(attachments),
lastSenderKey = account,
lastMessageFromMe = 1, lastMessageFromMe = 1,
lastMessageDelivered = 1, lastMessageDelivered = 1,
lastMessageRead = 1, lastMessageRead = 1,
@@ -875,6 +927,7 @@ class MessageRepository private constructor(private val context: Context) {
if (!stillExists) { if (!stillExists) {
// Сохраняем в БД только если сообщения нет // Сохраняем в БД только если сообщения нет
messageDao.insertMessage(entity) messageDao.insertMessage(entity)
upsertSearchIndex(account, entity, plainText)
MessageLogger.logDbSave(messageId, dialogKey, isNew = true) MessageLogger.logDbSave(messageId, dialogKey, isNew = true)
} else { } else {
MessageLogger.logDbSave(messageId, dialogKey, isNew = false) MessageLogger.logDbSave(messageId, dialogKey, isNew = false)
@@ -1411,7 +1464,8 @@ class MessageRepository private constructor(private val context: Context) {
opponentKey = opponentKey, opponentKey = opponentKey,
lastMessage = encryptedLastMessage, lastMessage = encryptedLastMessage,
lastMessageTimestamp = timestamp, 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 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: при * 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш Как в desktop: при
* получении attachment с типом AVATAR - сохраняем в avatar_cache * получении 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", "from_public_key", "to_public_key", "timestamp"]),
Index(value = ["account", "message_id"], unique = true), Index(value = ["account", "message_id"], unique = true),
Index(value = ["account", "dialog_key", "timestamp"]), Index(value = ["account", "dialog_key", "timestamp"]),
Index(value = ["account", "timestamp"]),
Index(value = ["account", "primary_attachment_type", "timestamp"])] Index(value = ["account", "primary_attachment_type", "timestamp"])]
) )
data class MessageEntity( data class MessageEntity(
@@ -107,13 +108,40 @@ data class MessageEntity(
@ColumnInfo(name = "dialog_key") val dialogKey: String // Ключ диалога для быстрой выборки @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 для диалогов (кэш последнего сообщения) */
@Entity( @Entity(
tableName = "dialogs", tableName = "dialogs",
indices = indices =
[ [
Index(value = ["account", "opponent_key"], unique = true), 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( data class DialogEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0, @PrimaryKey(autoGenerate = true) val id: Long = 0,
@@ -132,6 +160,12 @@ data class DialogEntity(
@ColumnInfo(name = "verified") val verified: Int = 0, // Верифицирован @ColumnInfo(name = "verified") val verified: Int = 0, // Верифицирован
@ColumnInfo(name = "i_have_sent", defaultValue = "0") @ColumnInfo(name = "i_have_sent", defaultValue = "0")
val iHaveSent: Int = 0, // Отправлял ли я сообщения в этот диалог (0/1) 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") @ColumnInfo(name = "last_message_from_me", defaultValue = "0")
val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1) val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
@ColumnInfo(name = "last_message_delivered", defaultValue = "0") @ColumnInfo(name = "last_message_delivered", defaultValue = "0")
@@ -548,10 +582,7 @@ interface MessageDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account WHERE account = :account
AND ( AND primary_attachment_type = 0
primary_attachment_type = 0
OR (primary_attachment_type = -1 AND attachments != '[]' AND attachments LIKE '%"type":0%')
)
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
""" """
@@ -566,10 +597,7 @@ interface MessageDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account WHERE account = :account
AND ( AND primary_attachment_type = 2
primary_attachment_type = 2
OR (primary_attachment_type = -1 AND attachments != '[]' AND attachments LIKE '%"type":2%')
)
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
""" """
@@ -600,14 +628,7 @@ interface MessageDao {
ELSE m.from_public_key ELSE m.from_public_key
END END
WHERE m.account = :account WHERE m.account = :account
AND ( AND m.primary_attachment_type = 4
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%')
)
)
ORDER BY m.timestamp DESC, m.message_id DESC ORDER BY m.timestamp DESC, m.message_id DESC
LIMIT :limit LIMIT :limit
""" """
@@ -624,14 +645,7 @@ interface MessageDao {
END AS peer_key END AS peer_key
FROM messages FROM messages
WHERE account = :account WHERE account = :account
AND ( AND primary_attachment_type = 4
primary_attachment_type = 4
OR (
primary_attachment_type = -1
AND attachments != '[]'
AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%')
)
)
""" """
) )
suspend fun getCallHistoryPeers(account: String): List<String> suspend fun getCallHistoryPeers(account: String): List<String>
@@ -641,20 +655,13 @@ interface MessageDao {
""" """
DELETE FROM messages DELETE FROM messages
WHERE account = :account WHERE account = :account
AND ( AND primary_attachment_type = 4
primary_attachment_type = 4
OR (
primary_attachment_type = -1
AND attachments != '[]'
AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%')
)
)
""" """
) )
suspend fun deleteAllCallMessages(account: String): Int suspend fun deleteAllCallMessages(account: String): Int
/** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */ /** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */
@Query( @Query(
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account WHERE account = :account
@@ -662,10 +669,51 @@ interface MessageDao {
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
""" """
) )
suspend fun getAllMessagesPaged(account: String, limit: Int, offset: Int): List<MessageEntity> 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 для работы с диалогами */
@Dao @Dao
interface DialogDao { interface DialogDao {
@@ -687,7 +735,7 @@ interface DialogDao {
OR opponent_key = '0x000000000000000000000000000000000000000001' OR opponent_key = '0x000000000000000000000000000000000000000001'
OR opponent_key = '0x000000000000000000000000000000000000000002' OR opponent_key = '0x000000000000000000000000000000000000000002'
) )
AND (last_message != '' OR last_message_attachments != '[]') AND has_content = 1
ORDER BY last_message_timestamp DESC ORDER BY last_message_timestamp DESC
LIMIT 30 LIMIT 30
""" """
@@ -704,7 +752,7 @@ interface DialogDao {
OR opponent_key = '0x000000000000000000000000000000000000000001' OR opponent_key = '0x000000000000000000000000000000000000000001'
OR opponent_key = '0x000000000000000000000000000000000000000002' OR opponent_key = '0x000000000000000000000000000000000000000002'
) )
AND (last_message != '' OR last_message_attachments != '[]') AND has_content = 1
ORDER BY last_message_timestamp DESC ORDER BY last_message_timestamp DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
""" """
@@ -722,7 +770,7 @@ interface DialogDao {
AND i_have_sent = 0 AND i_have_sent = 0
AND opponent_key != '0x000000000000000000000000000000000000000001' AND opponent_key != '0x000000000000000000000000000000000000000001'
AND opponent_key != '0x000000000000000000000000000000000000000002' AND opponent_key != '0x000000000000000000000000000000000000000002'
AND (last_message != '' OR last_message_attachments != '[]') AND has_content = 1
ORDER BY last_message_timestamp DESC ORDER BY last_message_timestamp DESC
LIMIT 30 LIMIT 30
""" """
@@ -737,7 +785,7 @@ interface DialogDao {
AND i_have_sent = 0 AND i_have_sent = 0
AND opponent_key != '0x000000000000000000000000000000000000000001' AND opponent_key != '0x000000000000000000000000000000000000000001'
AND opponent_key != '0x000000000000000000000000000000000000000002' AND opponent_key != '0x000000000000000000000000000000000000000002'
AND (last_message != '' OR last_message_attachments != '[]') AND has_content = 1
""" """
) )
fun getRequestsCountFlow(account: String): Flow<Int> fun getRequestsCountFlow(account: String): Flow<Int>
@@ -750,7 +798,7 @@ interface DialogDao {
@Query(""" @Query("""
SELECT * FROM dialogs SELECT * FROM dialogs
WHERE account = :account WHERE account = :account
AND (last_message != '' OR last_message_attachments != '[]') AND has_content = 1
AND opponent_key NOT LIKE '#group:%' AND opponent_key NOT LIKE '#group:%'
AND ( AND (
opponent_title = '' opponent_title = ''
@@ -781,7 +829,8 @@ interface DialogDao {
""" """
UPDATE dialogs SET UPDATE dialogs SET
last_message = :lastMessage, 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 WHERE account = :account AND opponent_key = :opponentKey
""" """
) )
@@ -1010,6 +1059,16 @@ interface DialogDao {
val hasSent = hasSentByDialogKey(account, dialogKey) val hasSent = hasSentByDialogKey(account, dialogKey)
// 5. Один INSERT OR REPLACE с вычисленными данными // 5. Один INSERT OR REPLACE с вычисленными данными
val hasContent =
if (
lastMsg.plainMessage.isNotBlank() ||
(lastMsg.attachments.isNotBlank() &&
lastMsg.attachments.trim() != "[]")
) {
1
} else {
0
}
insertDialog( insertDialog(
DialogEntity( DialogEntity(
id = existing?.id ?: 0, id = existing?.id ?: 0,
@@ -1025,6 +1084,9 @@ interface DialogDao {
verified = existing?.verified ?: 0, verified = existing?.verified ?: 0,
// Desktop parity: request flag is always derived from message history. // Desktop parity: request flag is always derived from message history.
iHaveSent = if (hasSent || isSystemDialog) 1 else 0, iHaveSent = if (hasSent || isSystemDialog) 1 else 0,
hasContent = hasContent,
lastMessageAttachmentType = lastMsg.primaryAttachmentType,
lastSenderKey = lastMsg.fromPublicKey,
lastMessageFromMe = lastMsg.fromMe, lastMessageFromMe = lastMsg.fromMe,
lastMessageDelivered = if (lastMsg.fromMe == 1) lastMsg.delivered else 0, lastMessageDelivered = if (lastMsg.fromMe == 1) lastMsg.delivered else 0,
lastMessageRead = if (lastMsg.fromMe == 1) lastMsg.read else 0, lastMessageRead = if (lastMsg.fromMe == 1) lastMsg.read else 0,
@@ -1044,6 +1106,16 @@ interface DialogDao {
val lastMsg = getLastMessageByDialogKey(account, dialogKey) ?: return val lastMsg = getLastMessageByDialogKey(account, dialogKey) ?: return
val existing = getDialog(account, account) val existing = getDialog(account, account)
val hasContent =
if (
lastMsg.plainMessage.isNotBlank() ||
(lastMsg.attachments.isNotBlank() &&
lastMsg.attachments.trim() != "[]")
) {
1
} else {
0
}
insertDialog( insertDialog(
DialogEntity( DialogEntity(
@@ -1059,6 +1131,9 @@ interface DialogDao {
lastSeen = existing?.lastSeen ?: 0, lastSeen = existing?.lastSeen ?: 0,
verified = existing?.verified ?: 0, verified = existing?.verified ?: 0,
iHaveSent = 1, iHaveSent = 1,
hasContent = hasContent,
lastMessageAttachmentType = lastMsg.primaryAttachmentType,
lastSenderKey = lastMsg.fromPublicKey,
lastMessageFromMe = 1, lastMessageFromMe = 1,
lastMessageDelivered = 1, lastMessageDelivered = 1,
lastMessageRead = 1, lastMessageRead = 1,

View File

@@ -12,13 +12,14 @@ import androidx.sqlite.db.SupportSQLiteDatabase
[ [
EncryptedAccountEntity::class, EncryptedAccountEntity::class,
MessageEntity::class, MessageEntity::class,
MessageSearchIndexEntity::class,
DialogEntity::class, DialogEntity::class,
BlacklistEntity::class, BlacklistEntity::class,
AvatarCacheEntity::class, AvatarCacheEntity::class,
AccountSyncTimeEntity::class, AccountSyncTimeEntity::class,
GroupEntity::class, GroupEntity::class,
PinnedMessageEntity::class], PinnedMessageEntity::class],
version = 15, version = 17,
exportSchema = false exportSchema = false
) )
abstract class RosettaDatabase : RoomDatabase() { abstract class RosettaDatabase : RoomDatabase() {
@@ -30,6 +31,7 @@ abstract class RosettaDatabase : RoomDatabase() {
abstract fun syncTimeDao(): SyncTimeDao abstract fun syncTimeDao(): SyncTimeDao
abstract fun groupDao(): GroupDao abstract fun groupDao(): GroupDao
abstract fun pinnedMessageDao(): PinnedMessageDao abstract fun pinnedMessageDao(): PinnedMessageDao
abstract fun messageSearchIndexDao(): MessageSearchIndexDao
companion object { companion object {
@Volatile private var INSTANCE: RosettaDatabase? = null @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 { fun getDatabase(context: Context): RosettaDatabase {
return INSTANCE return INSTANCE
?: synchronized(this) { ?: synchronized(this) {
@@ -255,7 +375,9 @@ abstract class RosettaDatabase : RoomDatabase() {
MIGRATION_11_12, MIGRATION_11_12,
MIGRATION_12_13, MIGRATION_12_13,
MIGRATION_13_14, MIGRATION_13_14,
MIGRATION_14_15 MIGRATION_14_15,
MIGRATION_15_16,
MIGRATION_16_17
) )
.fallbackToDestructiveMigration() // Для разработки - только .fallbackToDestructiveMigration() // Для разработки - только
// если миграция не // если миграция не

View File

@@ -98,6 +98,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val database = RosettaDatabase.getDatabase(application) private val database = RosettaDatabase.getDatabase(application)
private val dialogDao = database.dialogDao() private val dialogDao = database.dialogDao()
private val messageDao = database.messageDao() private val messageDao = database.messageDao()
private val searchIndexDao = database.messageSearchIndexDao()
private val groupDao = database.groupDao() private val groupDao = database.groupDao()
private val pinnedMessageDao = database.pinnedMessageDao() private val pinnedMessageDao = database.pinnedMessageDao()
@@ -126,6 +127,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// UI State // UI State
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList()) private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow() 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 * 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) .debounce(16) // coalesce rapid updates (1 frame)
.mapLatest { rawMessages -> .mapLatest { rawMessages ->
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val unique = rawMessages.distinctBy { it.id } val normalized =
val sorted = unique.sortedWith(chatMessageDescComparator) normalizeMessagesDescendingIncremental(
val result = ArrayList<Pair<ChatMessage, Boolean>>(sorted.size) previous = normalizedMessagesDescCache,
var prevDateStr: String? = null incoming = rawMessages
for (i in sorted.indices) { )
val msg = sorted[i] normalizedMessagesDescCache = normalized
val dateStr = _dateFmt.format(msg.timestamp) buildMessagesWithDateHeaders(normalized)
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>>
} }
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
@@ -234,6 +229,98 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private fun sortMessagesAscending(messages: List<ChatMessage>): List<ChatMessage> = private fun sortMessagesAscending(messages: List<ChatMessage>): List<ChatMessage> =
messages.sortedWith(chatMessageAscComparator) 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? = private fun latestIncomingMessage(messages: List<ChatMessage>): ChatMessage? =
messages.asSequence().filter { !it.isOutgoing }.maxWithOrNull(chatMessageAscComparator) messages.asSequence().filter { !it.isOutgoing }.maxWithOrNull(chatMessageAscComparator)
@@ -4648,6 +4735,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
) )
val insertedId = messageDao.insertMessage(entity) 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) {} } catch (e: Exception) {}
} }

View File

@@ -286,9 +286,6 @@ fun ChatsListScreen(
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val sduUpdateState by UpdateManager.updateState.collectAsState() 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) } val themeRevealRadius = remember { Animatable(0f) }
var rootSize by remember { mutableStateOf(IntSize.Zero) } var rootSize by remember { mutableStateOf(IntSize.Zero) }
var themeToggleCenterInRoot by remember { mutableStateOf<Offset?>(null) } var themeToggleCenterInRoot by remember { mutableStateOf<Offset?>(null) }
@@ -297,73 +294,6 @@ fun ChatsListScreen(
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) } var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) } 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() { fun startThemeReveal() {
if (themeRevealActive) { if (themeRevealActive) {
return return
@@ -2167,27 +2097,27 @@ fun ChatsListScreen(
} }
// 🔥 Берем dialogs из chatsState для // 🔥 Берем dialogs из chatsState для
// консистентности // консистентности
// 📌 Сортируем: pinned сначала, потом по // 📌 Порядок по времени готовится в ViewModel.
// времени // Здесь поднимаем pinned наверх без полного sort/distinct.
val currentDialogs = val currentDialogs =
remember( remember(
chatsState.dialogs, chatsState.dialogs,
pinnedChats pinnedChats
) { ) {
chatsState.dialogs val pinned = ArrayList<DialogUiModel>()
.distinctBy { it.opponentKey } val regular = ArrayList<DialogUiModel>()
.sortedWith( chatsState.dialogs.forEach { dialog ->
compareByDescending< if (
DialogUiModel> { pinnedChats.contains(
pinnedChats dialog.opponentKey
.contains( )
it.opponentKey ) {
) pinned.add(dialog)
} } else {
.thenByDescending { regular.add(dialog)
it.lastMessageTimestamp }
} }
) pinned + regular
} }
// Telegram-style: only one item can be // 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.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -80,6 +78,14 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл // 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл
private val subscribedOnlineKeys = mutableSetOf<String>() 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 для отмены подписок при смене аккаунта // Job для отмены подписок при смене аккаунта
private var accountSubscriptionsJob: Job? = null private var accountSubscriptionsJob: Job? = null
@@ -147,19 +153,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
if (!isGroupKey(dialog.opponentKey) || dialog.lastMessageTimestamp <= 0L) return null if (!isGroupKey(dialog.opponentKey) || dialog.lastMessageTimestamp <= 0L) return null
val senderKey = val senderKey =
if (dialog.lastMessageFromMe == 1) { dialog.lastSenderKey.trim().ifBlank {
currentAccount if (dialog.lastMessageFromMe == 1) currentAccount else ""
} else {
val lastMessage =
try {
dialogDao.getLastMessageByDialogKey(
account = dialog.account,
dialogKey = dialog.opponentKey.trim()
)
} catch (_: Exception) {
null
}
lastMessage?.fromPublicKey.orEmpty()
} }
if (senderKey.isBlank()) return null if (senderKey.isBlank()) return null
@@ -226,6 +221,123 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
return null 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) { fun setAccount(publicKey: String, privateKey: String) {
val setAccountStart = System.currentTimeMillis() val setAccountStart = System.currentTimeMillis()
@@ -241,6 +353,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта // 🔥 Очищаем кэш запрошенных user info при смене аккаунта
requestedUserInfoKeys.clear() requestedUserInfoKeys.clear()
subscribedOnlineKeys.clear() subscribedOnlineKeys.clear()
dialogsUiCache.clear()
requestsUiCache.clear()
// 🔥 Очищаем глобальный кэш сообщений ChatViewModel при смене аккаунта // 🔥 Очищаем глобальный кэш сообщений ChatViewModel при смене аккаунта
// чтобы избежать показа сообщений с неправильным isOutgoing // чтобы избежать показа сообщений с неправильным isOutgoing
@@ -280,104 +394,16 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка) .debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
.map { dialogsList -> .map { dialogsList ->
val mapStart = System.currentTimeMillis() mapDialogListIncremental(
// <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений dialogsList = dialogsList,
withContext(Dispatchers.Default) { privateKey = privateKey,
dialogsList cache = dialogsUiCache,
.map { dialog -> isRequestsFlow = false
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
}
} }
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedDialogs -> .collect { decryptedDialogs ->
// Deduplicate by opponentKey to prevent LazyColumn crash _dialogs.value = decryptedDialogs
// (Key "X" was already used)
_dialogs.value = decryptedDialogs.distinctBy { it.opponentKey }
// 🚀 Убираем skeleton после первой загрузки // 🚀 Убираем skeleton после первой загрузки
if (_isLoading.value) _isLoading.value = false if (_isLoading.value) _isLoading.value = false
@@ -400,83 +426,15 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.debounce(100) // 🚀 Батчим быстрые обновления .debounce(100) // 🚀 Батчим быстрые обновления
.map { requestsList -> .map { requestsList ->
// 🚀 ОПТИМИЗАЦИЯ: Параллельная расшифровка mapDialogListIncremental(
withContext(Dispatchers.Default) { dialogsList = requestsList,
requestsList privateKey = privateKey,
.map { dialog -> cache = requestsUiCache,
async { isRequestsFlow = true
// 🔥 Загружаем информацию о пользователе если её нет )
// 📁 НЕ загружаем для 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()
}
} }
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedRequests -> .collect { decryptedRequests -> _requests.value = decryptedRequests }
_requests.value = decryptedRequests.distinctBy { it.opponentKey }
}
} }
// 📊 Подписываемся на количество requests // 📊 Подписываемся на количество requests
@@ -584,38 +542,20 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} }
private fun resolveAttachmentType( private fun resolveAttachmentType(
attachmentsJson: String, attachmentType: Int,
decryptedLastMessage: String decryptedLastMessage: String
): String? { ): String? {
if (attachmentsJson.isEmpty() || attachmentsJson == "[]") return null return when (attachmentType) {
0 -> "Photo" // AttachmentType.IMAGE = 0
return try { 1 -> {
val attachments = org.json.JSONArray(attachmentsJson) // AttachmentType.MESSAGES = 1 (Reply/Forward).
if (attachments.length() == 0) return null // Если текст пустой — показываем "Forwarded" как в desktop.
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
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
} }
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.value = _requests.value.filter { it.opponentKey != opponentKey }
// 🔥 Обновляем счетчик requests // 🔥 Обновляем счетчик requests
_requestsCount.value = _requests.value.size _requestsCount.value = _requests.value.size
dialogsUiCache.remove(opponentKey)
requestsUiCache.remove(opponentKey)
// Вычисляем правильный dialog_key // Вычисляем правильный dialog_key
val dialogKey = val dialogKey =
@@ -760,6 +702,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
_dialogs.value = _dialogs.value.filter { it.opponentKey != groupPublicKey } _dialogs.value = _dialogs.value.filter { it.opponentKey != groupPublicKey }
_requests.value = _requests.value.filter { it.opponentKey != groupPublicKey } _requests.value = _requests.value.filter { it.opponentKey != groupPublicKey }
_requestsCount.value = _requests.value.size _requestsCount.value = _requests.value.size
dialogsUiCache.remove(groupPublicKey)
requestsUiCache.remove(groupPublicKey)
MessageRepository.getInstance(getApplication()).clearDialogCache(groupPublicKey) MessageRepository.getInstance(getApplication()).clearDialogCache(groupPublicKey)
ChatViewModel.clearCacheForOpponent(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.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.database.RosettaDatabase 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.AttachmentType
import com.rosetta.messenger.network.MessageAttachment import com.rosetta.messenger.network.MessageAttachment
import androidx.compose.ui.graphics.asImageBitmap 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.network.SearchUser
import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue
import kotlinx.coroutines.Dispatchers 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.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
@@ -1005,9 +1002,10 @@ private fun MessagesTabContent(
} }
// Persistent decryption cache: messageId → plaintext (survives re-queries) // 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) // 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()) } val dateFormat = remember { SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()) }
@@ -1044,67 +1042,66 @@ private fun MessagesTabContent(
} }
val queryLower = searchQuery.trim().lowercase() 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 maxResults = 50 // Don't return more than 50 matches
val batchSize = 200
val indexDao = db.messageSearchIndexDao()
while (offset < maxMessages && matched.size < maxResults) { fun toUiResult(item: MessageSearchIndexEntity): MessageSearchResult {
val batch = db.messageDao().getAllMessagesPaged( val normalized = item.opponentKey.trim()
currentUserPublicKey, batchSize, offset 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) { } catch (_: Exception) {
results = emptyList() results = emptyList()
} }