Оптимизация
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() // Для разработки - только
|
||||
// если миграция не
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user