Compare commits
7 Commits
93a2de315a
...
aa40f5287c
| Author | SHA1 | Date | |
|---|---|---|---|
| aa40f5287c | |||
| b271917594 | |||
| 4cfa9f1d48 | |||
| 20c6696fdf | |||
| 3eac17d9a8 | |||
| 84aad5f094 | |||
| e7efe0856c |
@@ -1,5 +1,14 @@
|
|||||||
# Release Notes
|
# Release Notes
|
||||||
|
|
||||||
|
## 1.3.3
|
||||||
|
|
||||||
|
### E2EE, чаты и производительность
|
||||||
|
- В release-сборке отключена frame-диагностика E2EE (детальный frame dump теперь только в debug).
|
||||||
|
- В `ChatsListScreen` убран двойной `collectAsState(chatsState)` и вынесены route-блоки в подкомпоненты (`CallsRouteContent`, `RequestsRouteContent`, общий `SwipeBackContainer`).
|
||||||
|
- Добавлена денормализация `primary_attachment_type` в таблице `messages` + индекс `(account, primary_attachment_type, timestamp)`.
|
||||||
|
- Обновлена миграция БД `14 -> 15`: добавление колонки, индекс и backfill значения типа вложения для уже сохраненных сообщений.
|
||||||
|
- Поисковые и call-history запросы переведены на `primary_attachment_type` с fallback на legacy `attachments LIKE` для старых записей.
|
||||||
|
|
||||||
## 1.2.3
|
## 1.2.3
|
||||||
|
|
||||||
### Групповые чаты и медиа
|
### Групповые чаты и медиа
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
|||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Rosetta versioning — bump here on each release
|
// Rosetta versioning — bump here on each release
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val rosettaVersionName = "1.3.2"
|
val rosettaVersionName = "1.3.3"
|
||||||
val rosettaVersionCode = 34 // Increment on each release
|
val rosettaVersionCode = 35 // Increment on each release
|
||||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -83,6 +83,14 @@ android {
|
|||||||
// Enable baseline profiles in debug builds too for testing
|
// Enable baseline profiles in debug builds too for testing
|
||||||
// Remove this in production
|
// Remove this in production
|
||||||
}
|
}
|
||||||
|
create("benchmark") {
|
||||||
|
initWith(getByName("release"))
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
matchingFallbacks += listOf("release")
|
||||||
|
isDebuggable = false
|
||||||
|
isMinifyEnabled = false
|
||||||
|
isShrinkResources = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
@@ -192,7 +200,7 @@ dependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Baseline Profiles for startup performance
|
// Baseline Profiles for startup performance
|
||||||
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
|
implementation("androidx.profileinstaller:profileinstaller:1.4.1")
|
||||||
|
|
||||||
// Firebase Cloud Messaging
|
// Firebase Cloud Messaging
|
||||||
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
|
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.firebase.FirebaseApp
|
import com.google.firebase.FirebaseApp
|
||||||
@@ -586,6 +590,8 @@ fun MainScreen(
|
|||||||
|
|
||||||
// Load username AND name from AccountManager (persisted in DataStore)
|
// Load username AND name from AccountManager (persisted in DataStore)
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val rootView = LocalView.current
|
||||||
val callScope = rememberCoroutineScope()
|
val callScope = rememberCoroutineScope()
|
||||||
val callUiState by CallManager.state.collectAsState()
|
val callUiState by CallManager.state.collectAsState()
|
||||||
var pendingOutgoingCall by remember { mutableStateOf<SearchUser?>(null) }
|
var pendingOutgoingCall by remember { mutableStateOf<SearchUser?>(null) }
|
||||||
@@ -757,6 +763,19 @@ fun MainScreen(
|
|||||||
CallManager.bindAccount(accountPublicKey)
|
CallManager.bindAccount(accountPublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(callUiState.isVisible) {
|
||||||
|
if (callUiState.isVisible) {
|
||||||
|
focusManager.clearFocus(force = true)
|
||||||
|
|
||||||
|
// Fallback for cases where IME survives focus reset due to window transitions.
|
||||||
|
val activity = rootView.context as? android.app.Activity
|
||||||
|
activity?.window?.let { window ->
|
||||||
|
WindowCompat.getInsetsController(window, rootView)
|
||||||
|
?.hide(WindowInsetsCompat.Type.ime())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(accountPublicKey, reloadTrigger) {
|
LaunchedEffect(accountPublicKey, reloadTrigger) {
|
||||||
if (accountPublicKey.isNotBlank()) {
|
if (accountPublicKey.isNotBlank()) {
|
||||||
val accountManager = AccountManager(context)
|
val accountManager = AccountManager(context)
|
||||||
|
|||||||
@@ -456,6 +456,7 @@ class GroupRepository private constructor(context: Context) {
|
|||||||
messageId = UUID.randomUUID().toString().replace("-", "").take(32),
|
messageId = UUID.randomUUID().toString().replace("-", "").take(32),
|
||||||
plainMessage = encryptedPlainMessage,
|
plainMessage = encryptedPlainMessage,
|
||||||
attachments = "[]",
|
attachments = "[]",
|
||||||
|
primaryAttachmentType = -1,
|
||||||
dialogKey = dialogPublicKey
|
dialogKey = dialogPublicKey
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
@@ -207,12 +209,32 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
plainMessage = encryptedPlainMessage,
|
plainMessage = encryptedPlainMessage,
|
||||||
attachments = "[]",
|
attachments = "[]",
|
||||||
|
primaryAttachmentType = -1,
|
||||||
dialogKey = dialogKey
|
dialogKey = dialogKey
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
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(
|
||||||
@@ -266,12 +288,32 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
plainMessage = encryptedPlainMessage,
|
plainMessage = encryptedPlainMessage,
|
||||||
attachments = "[]",
|
attachments = "[]",
|
||||||
|
primaryAttachmentType = -1,
|
||||||
dialogKey = dialogKey
|
dialogKey = dialogKey
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
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(
|
||||||
@@ -528,10 +570,13 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
||||||
attachments = attachmentsJson,
|
attachments = attachmentsJson,
|
||||||
|
primaryAttachmentType =
|
||||||
|
resolvePrimaryAttachmentType(attachments),
|
||||||
replyToMessageId = replyToMessageId,
|
replyToMessageId = replyToMessageId,
|
||||||
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)
|
||||||
@@ -559,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,
|
||||||
@@ -860,6 +916,8 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
messageId = messageId, // 🔥 Используем сгенерированный messageId!
|
messageId = messageId, // 🔥 Используем сгенерированный messageId!
|
||||||
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
||||||
attachments = attachmentsJson,
|
attachments = attachmentsJson,
|
||||||
|
primaryAttachmentType =
|
||||||
|
resolvePrimaryAttachmentType(packet.attachments),
|
||||||
dialogKey = dialogKey
|
dialogKey = dialogKey
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -869,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)
|
||||||
@@ -1405,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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1638,6 +1698,31 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
return jsonArray.toString()
|
return jsonArray.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolvePrimaryAttachmentType(attachments: List<MessageAttachment>): Int {
|
||||||
|
if (attachments.isEmpty()) return -1
|
||||||
|
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
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ object ReleaseNotes {
|
|||||||
val RELEASE_NOTICE = """
|
val RELEASE_NOTICE = """
|
||||||
Update v$VERSION_PLACEHOLDER
|
Update v$VERSION_PLACEHOLDER
|
||||||
|
|
||||||
Защищенные звонки и диагностика E2EE
|
Оптимизация производительности и стабильности
|
||||||
- Обновлен custom WebRTC для Android и исправлена совместимость аудио E2EE с Desktop
|
- В release отключена frame-диагностика E2EE (детальные frame-логи оставлены только в debug)
|
||||||
- Улучшены diagnostics для шифрования звонков (детализация ENC/DEC в crash reports)
|
- Оптимизирован чат-лист: убрано дублирование collectAsState и вынесены route-компоненты
|
||||||
- В Crash Reports добавлена кнопка копирования полного лога одним действием
|
- Ускорены выборки по вложениям: добавлен denormalized attachment type и индекс в БД
|
||||||
|
- Добавлен macrobenchmark-модуль с замерами startup, search и chat list scroll
|
||||||
|
- Исправлено поведение UI в звонке: клавиатура автоматически закрывается при открытии call overlay
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ 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"])]
|
||||||
)
|
)
|
||||||
data class MessageEntity(
|
data class MessageEntity(
|
||||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
@@ -99,18 +101,47 @@ data class MessageEntity(
|
|||||||
@ColumnInfo(name = "plain_message")
|
@ColumnInfo(name = "plain_message")
|
||||||
val plainMessage: String, // 🔒 Зашифрованный текст (encryptWithPassword) для хранения в БД
|
val plainMessage: String, // 🔒 Зашифрованный текст (encryptWithPassword) для хранения в БД
|
||||||
@ColumnInfo(name = "attachments") val attachments: String = "[]", // JSON массив вложений
|
@ColumnInfo(name = "attachments") val attachments: String = "[]", // JSON массив вложений
|
||||||
|
@ColumnInfo(name = "primary_attachment_type", defaultValue = "-1")
|
||||||
|
val primaryAttachmentType: Int = -1, // Денормализованный тип 1-го вложения (-1 если нет)
|
||||||
@ColumnInfo(name = "reply_to_message_id")
|
@ColumnInfo(name = "reply_to_message_id")
|
||||||
val replyToMessageId: String? = null, // ID цитируемого сообщения
|
val replyToMessageId: String? = null, // ID цитируемого сообщения
|
||||||
@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,
|
||||||
@@ -129,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")
|
||||||
@@ -545,8 +582,7 @@ interface MessageDao {
|
|||||||
"""
|
"""
|
||||||
SELECT * FROM messages
|
SELECT * FROM messages
|
||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
AND attachments != '[]'
|
AND primary_attachment_type = 0
|
||||||
AND attachments LIKE '%"type":0%'
|
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT :limit OFFSET :offset
|
LIMIT :limit OFFSET :offset
|
||||||
"""
|
"""
|
||||||
@@ -561,8 +597,7 @@ interface MessageDao {
|
|||||||
"""
|
"""
|
||||||
SELECT * FROM messages
|
SELECT * FROM messages
|
||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
AND attachments != '[]'
|
AND primary_attachment_type = 2
|
||||||
AND attachments LIKE '%"type":2%'
|
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT :limit OFFSET :offset
|
LIMIT :limit OFFSET :offset
|
||||||
"""
|
"""
|
||||||
@@ -593,8 +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 m.attachments != '[]'
|
AND m.primary_attachment_type = 4
|
||||||
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
|
||||||
"""
|
"""
|
||||||
@@ -611,8 +645,7 @@ interface MessageDao {
|
|||||||
END AS peer_key
|
END AS peer_key
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
AND attachments != '[]'
|
AND primary_attachment_type = 4
|
||||||
AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%')
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
suspend fun getCallHistoryPeers(account: String): List<String>
|
suspend fun getCallHistoryPeers(account: String): List<String>
|
||||||
@@ -622,14 +655,13 @@ interface MessageDao {
|
|||||||
"""
|
"""
|
||||||
DELETE FROM messages
|
DELETE FROM messages
|
||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
AND attachments != '[]'
|
AND primary_attachment_type = 4
|
||||||
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
|
||||||
@@ -637,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 {
|
||||||
@@ -662,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
|
||||||
"""
|
"""
|
||||||
@@ -679,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
|
||||||
"""
|
"""
|
||||||
@@ -697,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
|
||||||
"""
|
"""
|
||||||
@@ -712,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>
|
||||||
@@ -725,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 = ''
|
||||||
@@ -756,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
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -985,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,
|
||||||
@@ -1000,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,
|
||||||
@@ -1019,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(
|
||||||
@@ -1034,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,
|
||||||
|
|||||||
@@ -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 = 14,
|
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
|
||||||
@@ -202,6 +204,154 @@ abstract class RosettaDatabase : RoomDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🧱 МИГРАЦИЯ 14->15: Денормализованный тип вложения для ускорения фильтров (media/files/calls)
|
||||||
|
*/
|
||||||
|
private val MIGRATION_14_15 =
|
||||||
|
object : Migration(14, 15) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
database.execSQL(
|
||||||
|
"ALTER TABLE messages ADD COLUMN primary_attachment_type INTEGER NOT NULL DEFAULT -1"
|
||||||
|
)
|
||||||
|
database.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS index_messages_account_primary_attachment_type_timestamp ON messages (account, primary_attachment_type, timestamp)"
|
||||||
|
)
|
||||||
|
// Best-effort backfill для уже сохраненных сообщений.
|
||||||
|
database.execSQL(
|
||||||
|
"""
|
||||||
|
UPDATE messages
|
||||||
|
SET primary_attachment_type = CASE
|
||||||
|
WHEN attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]' THEN -1
|
||||||
|
WHEN attachments LIKE '%"type":0%' OR attachments LIKE '%"type": 0%' THEN 0
|
||||||
|
WHEN attachments LIKE '%"type":1%' OR attachments LIKE '%"type": 1%' THEN 1
|
||||||
|
WHEN attachments LIKE '%"type":2%' OR attachments LIKE '%"type": 2%' THEN 2
|
||||||
|
WHEN attachments LIKE '%"type":3%' OR attachments LIKE '%"type": 3%' THEN 3
|
||||||
|
WHEN attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%' THEN 4
|
||||||
|
ELSE -1
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🧱 МИГРАЦИЯ 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) {
|
||||||
@@ -224,7 +374,10 @@ abstract class RosettaDatabase : RoomDatabase() {
|
|||||||
MIGRATION_10_11,
|
MIGRATION_10_11,
|
||||||
MIGRATION_11_12,
|
MIGRATION_11_12,
|
||||||
MIGRATION_12_13,
|
MIGRATION_12_13,
|
||||||
MIGRATION_13_14
|
MIGRATION_13_14,
|
||||||
|
MIGRATION_14_15,
|
||||||
|
MIGRATION_15_16,
|
||||||
|
MIGRATION_16_17
|
||||||
)
|
)
|
||||||
.fallbackToDestructiveMigration() // Для разработки - только
|
.fallbackToDestructiveMigration() // Для разработки - только
|
||||||
// если миграция не
|
// если миграция не
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.rosetta.messenger.network
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.rosetta.messenger.BuildConfig
|
||||||
import com.rosetta.messenger.data.MessageRepository
|
import com.rosetta.messenger.data.MessageRepository
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
@@ -872,13 +873,15 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
sharedKeyBytes = keyBytes.copyOf(32)
|
sharedKeyBytes = keyBytes.copyOf(32)
|
||||||
breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}")
|
breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}")
|
||||||
// Open native diagnostics file for frame-level logging
|
// Frame-level diagnostics are enabled only for debug builds.
|
||||||
try {
|
if (BuildConfig.DEBUG) {
|
||||||
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
try {
|
||||||
if (!dir.exists()) dir.mkdirs()
|
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
||||||
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
|
if (!dir.exists()) dir.mkdirs()
|
||||||
XChaCha20E2EE.nativeOpenDiagFile(diagPath)
|
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
|
||||||
} catch (_: Throwable) {}
|
XChaCha20E2EE.nativeOpenDiagFile(diagPath)
|
||||||
|
} catch (_: Throwable) {}
|
||||||
|
}
|
||||||
// If sender track already exists, bind encryptor now.
|
// If sender track already exists, bind encryptor now.
|
||||||
val existingSender =
|
val existingSender =
|
||||||
pendingAudioSenderForE2ee
|
pendingAudioSenderForE2ee
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -4641,14 +4728,42 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
encryptedPlainMessage, // 🔒 Зашифрованный текст для хранения в
|
encryptedPlainMessage, // 🔒 Зашифрованный текст для хранения в
|
||||||
// БД
|
// БД
|
||||||
attachments = attachmentsJson,
|
attachments = attachmentsJson,
|
||||||
|
primaryAttachmentType =
|
||||||
|
resolvePrimaryAttachmentTypeFromJson(attachmentsJson),
|
||||||
replyToMessageId = null,
|
replyToMessageId = null,
|
||||||
dialogKey = dialogKey
|
dialogKey = dialogKey
|
||||||
)
|
)
|
||||||
|
|
||||||
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) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolvePrimaryAttachmentTypeFromJson(attachmentsJson: String): Int {
|
||||||
|
if (attachmentsJson.isBlank() || attachmentsJson == "[]") return -1
|
||||||
|
return try {
|
||||||
|
val array = JSONArray(attachmentsJson)
|
||||||
|
if (array.length() == 0) return -1
|
||||||
|
val first = array.optJSONObject(0) ?: return -1
|
||||||
|
first.optInt("type", -1)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun showTypingIndicator() {
|
private fun showTypingIndicator() {
|
||||||
_opponentTyping.value = true
|
_opponentTyping.value = true
|
||||||
// Отменяем предыдущий таймер, чтобы избежать race condition
|
// Отменяем предыдущий таймер, чтобы избежать race condition
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -666,6 +596,7 @@ fun ChatsListScreen(
|
|||||||
|
|
||||||
// Requests count for badge on hamburger & sidebar
|
// Requests count for badge on hamburger & sidebar
|
||||||
val topLevelChatsState by chatsViewModel.chatsState.collectAsState()
|
val topLevelChatsState by chatsViewModel.chatsState.collectAsState()
|
||||||
|
val topLevelIsLoading by chatsViewModel.isLoading.collectAsState()
|
||||||
val topLevelRequestsCount = topLevelChatsState.requestsCount
|
val topLevelRequestsCount = topLevelChatsState.requestsCount
|
||||||
|
|
||||||
// Dev console dialog - commented out for now
|
// Dev console dialog - commented out for now
|
||||||
@@ -1934,8 +1865,8 @@ fun ChatsListScreen(
|
|||||||
// Это предотвращает "дергание" UI когда dialogs и requests
|
// Это предотвращает "дергание" UI когда dialogs и requests
|
||||||
// обновляются
|
// обновляются
|
||||||
// независимо
|
// независимо
|
||||||
val chatsState by chatsViewModel.chatsState.collectAsState()
|
val chatsState = topLevelChatsState
|
||||||
val isLoading by chatsViewModel.isLoading.collectAsState()
|
val isLoading = topLevelIsLoading
|
||||||
val requests = chatsState.requests
|
val requests = chatsState.requests
|
||||||
val requestsCount = chatsState.requestsCount
|
val requestsCount = chatsState.requestsCount
|
||||||
|
|
||||||
@@ -2061,64 +1992,15 @@ fun ChatsListScreen(
|
|||||||
label = "CallsTransition"
|
label = "CallsTransition"
|
||||||
) { isCallsScreen ->
|
) { isCallsScreen ->
|
||||||
if (isCallsScreen) {
|
if (isCallsScreen) {
|
||||||
Box(
|
CallsRouteContent(
|
||||||
modifier = Modifier
|
isDarkTheme = isDarkTheme,
|
||||||
.fillMaxSize()
|
accountPublicKey = accountPublicKey,
|
||||||
.pointerInput(Unit) {
|
avatarRepository = avatarRepository,
|
||||||
val velocityTracker = androidx.compose.ui.input.pointer.util.VelocityTracker()
|
onUserSelect = onUserSelect,
|
||||||
awaitEachGesture {
|
onStartCall = onStartCall,
|
||||||
val down = awaitFirstDown(requireUnconsumed = false)
|
onStartNewCall = onSearchClick,
|
||||||
velocityTracker.resetTracking()
|
onBack = { setInlineCallsVisible(false) }
|
||||||
velocityTracker.addPosition(down.uptimeMillis, down.position)
|
)
|
||||||
var totalDragX = 0f
|
|
||||||
var totalDragY = 0f
|
|
||||||
var claimed = false
|
|
||||||
val touchSlop = viewConfiguration.touchSlop * 0.6f
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
val event = awaitPointerEvent()
|
|
||||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
|
||||||
if (change.changedToUpIgnoreConsumed()) break
|
|
||||||
|
|
||||||
val delta = change.positionChange()
|
|
||||||
totalDragX += delta.x
|
|
||||||
totalDragY += delta.y
|
|
||||||
velocityTracker.addPosition(change.uptimeMillis, change.position)
|
|
||||||
|
|
||||||
if (!claimed) {
|
|
||||||
val distance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
|
|
||||||
if (distance < touchSlop) continue
|
|
||||||
if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.2f) {
|
|
||||||
claimed = true
|
|
||||||
change.consume()
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
change.consume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (claimed) {
|
|
||||||
val velocityX = velocityTracker.calculateVelocity().x
|
|
||||||
val screenWidth = size.width.toFloat()
|
|
||||||
if (totalDragX > screenWidth * 0.08f || velocityX > 200f) {
|
|
||||||
setInlineCallsVisible(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
CallsHistoryScreen(
|
|
||||||
isDarkTheme = isDarkTheme,
|
|
||||||
accountPublicKey = accountPublicKey,
|
|
||||||
avatarRepository = avatarRepository,
|
|
||||||
onOpenChat = onUserSelect,
|
|
||||||
onStartCall = onStartCall,
|
|
||||||
onStartNewCall = onSearchClick,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 🎬 Animated content transition between main list and
|
// 🎬 Animated content transition between main list and
|
||||||
// requests
|
// requests
|
||||||
@@ -2154,110 +2036,42 @@ fun ChatsListScreen(
|
|||||||
label = "RequestsTransition"
|
label = "RequestsTransition"
|
||||||
) { isRequestsScreen ->
|
) { isRequestsScreen ->
|
||||||
if (isRequestsScreen) {
|
if (isRequestsScreen) {
|
||||||
// 📬 Show Requests Screen with swipe-back
|
RequestsRouteContent(
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
val velocityTracker = androidx.compose.ui.input.pointer.util.VelocityTracker()
|
|
||||||
awaitEachGesture {
|
|
||||||
val down = awaitFirstDown(requireUnconsumed = false)
|
|
||||||
velocityTracker.resetTracking()
|
|
||||||
velocityTracker.addPosition(down.uptimeMillis, down.position)
|
|
||||||
var totalDragX = 0f
|
|
||||||
var totalDragY = 0f
|
|
||||||
var claimed = false
|
|
||||||
val touchSlop = viewConfiguration.touchSlop * 0.6f
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
val event = awaitPointerEvent()
|
|
||||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
|
||||||
if (change.changedToUpIgnoreConsumed()) break
|
|
||||||
|
|
||||||
val delta = change.positionChange()
|
|
||||||
totalDragX += delta.x
|
|
||||||
totalDragY += delta.y
|
|
||||||
velocityTracker.addPosition(change.uptimeMillis, change.position)
|
|
||||||
|
|
||||||
if (!claimed) {
|
|
||||||
val distance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
|
|
||||||
if (distance < touchSlop) continue
|
|
||||||
if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.2f) {
|
|
||||||
claimed = true
|
|
||||||
change.consume()
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
change.consume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (claimed) {
|
|
||||||
val velocityX = velocityTracker.calculateVelocity().x
|
|
||||||
val screenWidth = size.width.toFloat()
|
|
||||||
if (totalDragX > screenWidth * 0.08f || velocityX > 200f) {
|
|
||||||
setInlineRequestsVisible(
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
RequestsScreen(
|
|
||||||
requests = requests,
|
requests = requests,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
onBack = {
|
avatarRepository = avatarRepository,
|
||||||
setInlineRequestsVisible(
|
|
||||||
false
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onRequestClick = { request ->
|
|
||||||
val user =
|
|
||||||
chatsViewModel
|
|
||||||
.dialogToSearchUser(
|
|
||||||
request
|
|
||||||
)
|
|
||||||
onUserSelect(user)
|
|
||||||
},
|
|
||||||
avatarRepository =
|
|
||||||
avatarRepository,
|
|
||||||
blockedUsers = blockedUsers,
|
blockedUsers = blockedUsers,
|
||||||
pinnedChats = pinnedChats,
|
pinnedChats = pinnedChats,
|
||||||
isDrawerOpen =
|
isDrawerOpen =
|
||||||
drawerState.isOpen ||
|
drawerState.isOpen ||
|
||||||
drawerState
|
drawerState.isAnimationRunning,
|
||||||
.isAnimationRunning,
|
onTogglePin = onTogglePin,
|
||||||
onTogglePin = { opponentKey ->
|
|
||||||
onTogglePin(opponentKey)
|
|
||||||
},
|
|
||||||
onDeleteDialog = { opponentKey ->
|
onDeleteDialog = { opponentKey ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
chatsViewModel
|
chatsViewModel.deleteDialog(opponentKey)
|
||||||
.deleteDialog(
|
|
||||||
opponentKey
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onBlockUser = { opponentKey ->
|
onBlockUser = { opponentKey ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
chatsViewModel
|
chatsViewModel.blockUser(opponentKey)
|
||||||
.blockUser(
|
|
||||||
opponentKey
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUnblockUser = { opponentKey ->
|
onUnblockUser = { opponentKey ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
chatsViewModel
|
chatsViewModel.unblockUser(opponentKey)
|
||||||
.unblockUser(
|
|
||||||
opponentKey
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onRequestClick = { request ->
|
||||||
|
val user =
|
||||||
|
chatsViewModel.dialogToSearchUser(
|
||||||
|
request
|
||||||
|
)
|
||||||
|
onUserSelect(user)
|
||||||
|
},
|
||||||
|
onBack = {
|
||||||
|
setInlineRequestsVisible(false)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} // Close Box wrapper
|
|
||||||
} else if (showSkeleton) {
|
} else if (showSkeleton) {
|
||||||
ChatsListSkeleton(isDarkTheme = isDarkTheme)
|
ChatsListSkeleton(isDarkTheme = isDarkTheme)
|
||||||
} else if (isLoading && chatsState.isEmpty) {
|
} else if (isLoading && chatsState.isEmpty) {
|
||||||
@@ -2283,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
|
||||||
@@ -4919,6 +4733,135 @@ fun TypingIndicatorSmall() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SwipeBackContainer(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable BoxScope.() -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
modifier.fillMaxSize().pointerInput(onBack) {
|
||||||
|
val velocityTracker = VelocityTracker()
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
velocityTracker.resetTracking()
|
||||||
|
velocityTracker.addPosition(down.uptimeMillis, down.position)
|
||||||
|
var totalDragX = 0f
|
||||||
|
var totalDragY = 0f
|
||||||
|
var claimed = false
|
||||||
|
val touchSlop = viewConfiguration.touchSlop * 0.6f
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent()
|
||||||
|
val change =
|
||||||
|
event.changes.firstOrNull { it.id == down.id }
|
||||||
|
?: break
|
||||||
|
if (change.changedToUpIgnoreConsumed()) break
|
||||||
|
|
||||||
|
val delta = change.positionChange()
|
||||||
|
totalDragX += delta.x
|
||||||
|
totalDragY += delta.y
|
||||||
|
velocityTracker.addPosition(
|
||||||
|
change.uptimeMillis,
|
||||||
|
change.position
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!claimed) {
|
||||||
|
val distance =
|
||||||
|
kotlin.math.sqrt(
|
||||||
|
totalDragX * totalDragX +
|
||||||
|
totalDragY * totalDragY
|
||||||
|
)
|
||||||
|
if (distance < touchSlop) continue
|
||||||
|
if (
|
||||||
|
totalDragX > 0 &&
|
||||||
|
kotlin.math.abs(totalDragX) >
|
||||||
|
kotlin.math.abs(totalDragY) * 1.2f
|
||||||
|
) {
|
||||||
|
claimed = true
|
||||||
|
change.consume()
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
change.consume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claimed) {
|
||||||
|
val velocityX = velocityTracker.calculateVelocity().x
|
||||||
|
val screenWidth = size.width.toFloat()
|
||||||
|
if (
|
||||||
|
totalDragX > screenWidth * 0.08f ||
|
||||||
|
velocityX > 200f
|
||||||
|
) {
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CallsRouteContent(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
accountPublicKey: String,
|
||||||
|
avatarRepository: AvatarRepository?,
|
||||||
|
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit,
|
||||||
|
onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit,
|
||||||
|
onStartNewCall: () -> Unit,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
SwipeBackContainer(onBack = onBack) {
|
||||||
|
CallsHistoryScreen(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
accountPublicKey = accountPublicKey,
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
|
onOpenChat = onUserSelect,
|
||||||
|
onStartCall = onStartCall,
|
||||||
|
onStartNewCall = onStartNewCall,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RequestsRouteContent(
|
||||||
|
requests: List<DialogUiModel>,
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
avatarRepository: AvatarRepository?,
|
||||||
|
blockedUsers: Set<String>,
|
||||||
|
pinnedChats: Set<String>,
|
||||||
|
isDrawerOpen: Boolean,
|
||||||
|
onTogglePin: (String) -> Unit,
|
||||||
|
onDeleteDialog: (String) -> Unit,
|
||||||
|
onBlockUser: (String) -> Unit,
|
||||||
|
onUnblockUser: (String) -> Unit,
|
||||||
|
onRequestClick: (DialogUiModel) -> Unit,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
SwipeBackContainer(onBack = onBack) {
|
||||||
|
RequestsScreen(
|
||||||
|
requests = requests,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
onBack = onBack,
|
||||||
|
onRequestClick = onRequestClick,
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
|
blockedUsers = blockedUsers,
|
||||||
|
pinnedChats = pinnedChats,
|
||||||
|
isDrawerOpen = isDrawerOpen,
|
||||||
|
onTogglePin = onTogglePin,
|
||||||
|
onDeleteDialog = onDeleteDialog,
|
||||||
|
onBlockUser = onBlockUser,
|
||||||
|
onUnblockUser = onUnblockUser
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 📬 Секция Requests — Telegram Archived Chats style */
|
/** 📬 Секция Requests — Telegram Archived Chats style */
|
||||||
@Composable
|
@Composable
|
||||||
fun RequestsSection(
|
fun RequestsSection(
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,5 +29,5 @@ dependencies {
|
|||||||
implementation("androidx.test.ext:junit:1.1.5")
|
implementation("androidx.test.ext:junit:1.1.5")
|
||||||
implementation("androidx.test.espresso:espresso-core:3.5.1")
|
implementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
implementation("androidx.test.uiautomator:uiautomator:2.2.0")
|
implementation("androidx.test.uiautomator:uiautomator:2.2.0")
|
||||||
implementation("androidx.benchmark:benchmark-macro-junit4:1.2.2")
|
implementation("androidx.benchmark:benchmark-macro-junit4:1.4.1")
|
||||||
}
|
}
|
||||||
|
|||||||
20
benchmark/README.md
Normal file
20
benchmark/README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Macrobenchmark
|
||||||
|
|
||||||
|
Этот модуль запускает замеры производительности приложения `:app` на устройстве:
|
||||||
|
|
||||||
|
- `coldStartup` — холодный запуск
|
||||||
|
- `chatListScroll` — прокрутка списка чатов
|
||||||
|
- `searchFlow` — вход в поиск и ввод запроса
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :benchmark:connectedCheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Запуск только одного класса:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :benchmark:connectedAndroidTest \
|
||||||
|
-Pandroid.testInstrumentationRunnerArguments.class=com.rosetta.messenger.benchmark.AppMacrobenchmark
|
||||||
|
```
|
||||||
37
benchmark/build.gradle.kts
Normal file
37
benchmark/build.gradle.kts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.test")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.rosetta.messenger.benchmark"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 28
|
||||||
|
targetSdk = 34
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
testInstrumentationRunnerArguments["androidx.benchmark.enabledRules"] = "Macrobenchmark"
|
||||||
|
testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "EMULATOR,DEBUGGABLE"
|
||||||
|
testInstrumentationRunnerArguments["androidx.benchmark.compilation.enabled"] = "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "11"
|
||||||
|
}
|
||||||
|
|
||||||
|
targetProjectPath = ":app"
|
||||||
|
experimentalProperties["android.experimental.self-instrumenting"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.benchmark:benchmark-macro-junit4:1.4.1")
|
||||||
|
implementation("androidx.test.ext:junit:1.1.5")
|
||||||
|
implementation("androidx.test:runner:1.5.2")
|
||||||
|
implementation("androidx.test.uiautomator:uiautomator:2.2.0")
|
||||||
|
}
|
||||||
2
benchmark/src/main/AndroidManifest.xml
Normal file
2
benchmark/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package com.rosetta.messenger.benchmark
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.benchmark.macro.CompilationMode
|
||||||
|
import androidx.benchmark.macro.FrameTimingMetric
|
||||||
|
import androidx.benchmark.macro.MacrobenchmarkScope
|
||||||
|
import androidx.benchmark.macro.StartupMode
|
||||||
|
import androidx.benchmark.macro.StartupTimingMetric
|
||||||
|
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.LargeTest
|
||||||
|
import androidx.test.uiautomator.By
|
||||||
|
import androidx.test.uiautomator.Direction
|
||||||
|
import androidx.test.uiautomator.UiObject2
|
||||||
|
import androidx.test.uiautomator.Until
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@LargeTest
|
||||||
|
class AppMacrobenchmark {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val benchmarkRule = MacrobenchmarkRule()
|
||||||
|
|
||||||
|
private val appPackage = "com.rosetta.messenger"
|
||||||
|
private val searchTriggers = listOf("Search", "search", "Поиск", "поиск", "Найти", "найти")
|
||||||
|
private val runtimePermissions = listOf(
|
||||||
|
"android.permission.POST_NOTIFICATIONS",
|
||||||
|
"android.permission.CAMERA",
|
||||||
|
"android.permission.RECORD_AUDIO",
|
||||||
|
"android.permission.READ_MEDIA_IMAGES",
|
||||||
|
"android.permission.READ_MEDIA_VIDEO",
|
||||||
|
"android.permission.BLUETOOTH_CONNECT"
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun coldStartup() {
|
||||||
|
benchmarkRule.measureRepeated(
|
||||||
|
packageName = appPackage,
|
||||||
|
metrics = listOf(StartupTimingMetric(), FrameTimingMetric()),
|
||||||
|
compilationMode = CompilationMode.Partial(),
|
||||||
|
startupMode = StartupMode.COLD,
|
||||||
|
iterations = 1,
|
||||||
|
setupBlock = {
|
||||||
|
grantRuntimePermissions()
|
||||||
|
pressHome()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
launchMainActivity()
|
||||||
|
device.waitForIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chatListScroll() {
|
||||||
|
benchmarkRule.measureRepeated(
|
||||||
|
packageName = appPackage,
|
||||||
|
metrics = listOf(FrameTimingMetric()),
|
||||||
|
compilationMode = CompilationMode.Partial(),
|
||||||
|
iterations = 1,
|
||||||
|
setupBlock = {
|
||||||
|
prepareUi()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val list = device.findObject(By.scrollable(true)) ?: return@measureRepeated
|
||||||
|
repeat(2) {
|
||||||
|
list.safeScroll(Direction.DOWN, 1.0f)
|
||||||
|
device.waitForIdle()
|
||||||
|
list.safeScroll(Direction.UP, 1.0f)
|
||||||
|
device.waitForIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchFlow() {
|
||||||
|
benchmarkRule.measureRepeated(
|
||||||
|
packageName = appPackage,
|
||||||
|
metrics = listOf(FrameTimingMetric()),
|
||||||
|
compilationMode = CompilationMode.Partial(),
|
||||||
|
iterations = 1,
|
||||||
|
setupBlock = {
|
||||||
|
prepareUi()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (!openSearchInput()) return@measureRepeated
|
||||||
|
|
||||||
|
val input = device.wait(Until.findObject(By.clazz("android.widget.EditText")), 1_500)
|
||||||
|
?: return@measureRepeated
|
||||||
|
|
||||||
|
input.text = "rosetta"
|
||||||
|
device.waitForIdle()
|
||||||
|
|
||||||
|
device.findObject(By.scrollable(true))?.safeScroll(Direction.DOWN, 0.7f)
|
||||||
|
device.waitForIdle()
|
||||||
|
|
||||||
|
device.pressBack()
|
||||||
|
device.waitForIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MacrobenchmarkScope.prepareUi() {
|
||||||
|
grantRuntimePermissions()
|
||||||
|
pressHome()
|
||||||
|
launchMainActivity()
|
||||||
|
device.waitForIdle()
|
||||||
|
device.wait(Until.hasObject(By.pkg(appPackage).depth(0)), 3_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MacrobenchmarkScope.openSearchInput(): Boolean {
|
||||||
|
if (device.hasObject(By.clazz("android.widget.EditText"))) return true
|
||||||
|
|
||||||
|
for (label in searchTriggers) {
|
||||||
|
val node = device.findObject(By.descContains(label)) ?: device.findObject(By.textContains(label))
|
||||||
|
if (node != null) {
|
||||||
|
node.click()
|
||||||
|
device.waitForIdle()
|
||||||
|
if (device.wait(Until.hasObject(By.clazz("android.widget.EditText")), 1_000)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return device.hasObject(By.clazz("android.widget.EditText"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun UiObject2.safeScroll(direction: Direction, percent: Float) {
|
||||||
|
runCatching { scroll(direction, percent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MacrobenchmarkScope.grantRuntimePermissions() {
|
||||||
|
runtimePermissions.forEach { permission ->
|
||||||
|
runCatching {
|
||||||
|
device.executeShellCommand("pm grant $appPackage $permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MacrobenchmarkScope.launchMainActivity() {
|
||||||
|
val intent = Intent(Intent.ACTION_MAIN).apply {
|
||||||
|
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||||
|
setClassName(appPackage, "$appPackage.MainActivity")
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||||
|
}
|
||||||
|
startActivityAndWait(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,3 +19,4 @@ rootProject.name = "rosetta-android"
|
|||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
include(":baselineprofile")
|
include(":baselineprofile")
|
||||||
|
include(":benchmark")
|
||||||
|
|||||||
Reference in New Issue
Block a user