Compare commits
9 Commits
new-server
...
b271917594
| Author | SHA1 | Date | |
|---|---|---|---|
| b271917594 | |||
| 4cfa9f1d48 | |||
| 20c6696fdf | |||
| 3eac17d9a8 | |||
| 84aad5f094 | |||
| e7efe0856c | |||
| c3e97eee56 | |||
| 39b0b0e107 | |||
| 51f76b5073 |
@@ -1,5 +1,14 @@
|
||||
# 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
|
||||
|
||||
### Групповые чаты и медиа
|
||||
|
||||
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Rosetta versioning — bump here on each release
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val rosettaVersionName = "1.3.2"
|
||||
val rosettaVersionCode = 34 // Increment on each release
|
||||
val rosettaVersionName = "1.3.3"
|
||||
val rosettaVersionCode = 35 // Increment on each release
|
||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||
|
||||
android {
|
||||
@@ -83,6 +83,14 @@ android {
|
||||
// Enable baseline profiles in debug builds too for testing
|
||||
// Remove this in production
|
||||
}
|
||||
create("benchmark") {
|
||||
initWith(getByName("release"))
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
matchingFallbacks += listOf("release")
|
||||
isDebuggable = false
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
@@ -192,7 +200,7 @@ dependencies {
|
||||
}
|
||||
|
||||
// Baseline Profiles for startup performance
|
||||
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
|
||||
implementation("androidx.profileinstaller:profileinstaller:1.4.1")
|
||||
|
||||
// Firebase Cloud Messaging
|
||||
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.graphics.Color
|
||||
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.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.firebase.FirebaseApp
|
||||
@@ -586,6 +590,8 @@ fun MainScreen(
|
||||
|
||||
// Load username AND name from AccountManager (persisted in DataStore)
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val rootView = LocalView.current
|
||||
val callScope = rememberCoroutineScope()
|
||||
val callUiState by CallManager.state.collectAsState()
|
||||
var pendingOutgoingCall by remember { mutableStateOf<SearchUser?>(null) }
|
||||
@@ -757,6 +763,19 @@ fun MainScreen(
|
||||
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) {
|
||||
if (accountPublicKey.isNotBlank()) {
|
||||
val accountManager = AccountManager(context)
|
||||
@@ -1015,6 +1034,9 @@ fun MainScreen(
|
||||
onUserSelect = { selectedChatUser ->
|
||||
pushScreen(Screen.ChatDetail(selectedChatUser))
|
||||
},
|
||||
onStartCall = { user ->
|
||||
startCallWithPermission(user)
|
||||
},
|
||||
backgroundBlurColorId = backgroundBlurColorId,
|
||||
pinnedChats = pinnedChats,
|
||||
onTogglePin = { opponentKey ->
|
||||
@@ -1277,7 +1299,8 @@ fun MainScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
chatWallpaperId = chatWallpaperId,
|
||||
avatarRepository = avatarRepository,
|
||||
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked }
|
||||
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked },
|
||||
isCallActive = callUiState.isVisible
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,6 +456,7 @@ class GroupRepository private constructor(context: Context) {
|
||||
messageId = UUID.randomUUID().toString().replace("-", "").take(32),
|
||||
plainMessage = encryptedPlainMessage,
|
||||
attachments = "[]",
|
||||
primaryAttachmentType = -1,
|
||||
dialogKey = dialogPublicKey
|
||||
)
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.rosetta.messenger.network.*
|
||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import com.rosetta.messenger.utils.MessageLogger
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
@@ -51,6 +52,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
private val avatarDao = database.avatarDao()
|
||||
private val syncTimeDao = database.syncTimeDao()
|
||||
private val groupDao = database.groupDao()
|
||||
private val searchIndexDao = database.messageSearchIndexDao()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@@ -207,12 +209,32 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
messageId = messageId,
|
||||
plainMessage = encryptedPlainMessage,
|
||||
attachments = "[]",
|
||||
primaryAttachmentType = -1,
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
)
|
||||
|
||||
if (inserted == -1L) return
|
||||
|
||||
val insertedMessage =
|
||||
MessageEntity(
|
||||
account = account,
|
||||
fromPublicKey = SYSTEM_SAFE_PUBLIC_KEY,
|
||||
toPublicKey = account,
|
||||
content = "",
|
||||
timestamp = timestamp,
|
||||
chachaKey = "",
|
||||
read = 0,
|
||||
fromMe = 0,
|
||||
delivered = DeliveryStatus.DELIVERED.value,
|
||||
messageId = messageId,
|
||||
plainMessage = encryptedPlainMessage,
|
||||
attachments = "[]",
|
||||
primaryAttachmentType = -1,
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
upsertSearchIndex(account, insertedMessage, messageText)
|
||||
|
||||
val existing = dialogDao.getDialog(account, SYSTEM_SAFE_PUBLIC_KEY)
|
||||
dialogDao.insertDialog(
|
||||
DialogEntity(
|
||||
@@ -266,12 +288,32 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
messageId = messageId,
|
||||
plainMessage = encryptedPlainMessage,
|
||||
attachments = "[]",
|
||||
primaryAttachmentType = -1,
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
)
|
||||
|
||||
if (inserted == -1L) return null
|
||||
|
||||
val insertedMessage =
|
||||
MessageEntity(
|
||||
account = account,
|
||||
fromPublicKey = SYSTEM_UPDATES_PUBLIC_KEY,
|
||||
toPublicKey = account,
|
||||
content = "",
|
||||
timestamp = timestamp,
|
||||
chachaKey = "",
|
||||
read = 0,
|
||||
fromMe = 0,
|
||||
delivered = DeliveryStatus.DELIVERED.value,
|
||||
messageId = messageId,
|
||||
plainMessage = encryptedPlainMessage,
|
||||
attachments = "[]",
|
||||
primaryAttachmentType = -1,
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
upsertSearchIndex(account, insertedMessage, messageText)
|
||||
|
||||
val existing = dialogDao.getDialog(account, SYSTEM_UPDATES_PUBLIC_KEY)
|
||||
dialogDao.insertDialog(
|
||||
DialogEntity(
|
||||
@@ -528,10 +570,13 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
messageId = messageId,
|
||||
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
||||
attachments = attachmentsJson,
|
||||
primaryAttachmentType =
|
||||
resolvePrimaryAttachmentType(attachments),
|
||||
replyToMessageId = replyToMessageId,
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
messageDao.insertMessage(entity)
|
||||
upsertSearchIndex(account, entity, text.trim())
|
||||
|
||||
// 📝 LOG: Сохранено в БД
|
||||
MessageLogger.logDbSave(messageId, dialogKey, isNew = true)
|
||||
@@ -559,6 +604,17 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
lastSeen = existing?.lastSeen ?: 0,
|
||||
verified = existing?.verified ?: 0,
|
||||
iHaveSent = 1,
|
||||
hasContent =
|
||||
if (
|
||||
encryptedPlainMessage.isNotBlank() ||
|
||||
attachments.isNotEmpty()
|
||||
) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
lastMessageAttachmentType = resolvePrimaryAttachmentType(attachments),
|
||||
lastSenderKey = account,
|
||||
lastMessageFromMe = 1,
|
||||
lastMessageDelivered = 1,
|
||||
lastMessageRead = 1,
|
||||
@@ -860,6 +916,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
messageId = messageId, // 🔥 Используем сгенерированный messageId!
|
||||
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
||||
attachments = attachmentsJson,
|
||||
primaryAttachmentType =
|
||||
resolvePrimaryAttachmentType(packet.attachments),
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
|
||||
@@ -869,6 +927,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
if (!stillExists) {
|
||||
// Сохраняем в БД только если сообщения нет
|
||||
messageDao.insertMessage(entity)
|
||||
upsertSearchIndex(account, entity, plainText)
|
||||
MessageLogger.logDbSave(messageId, dialogKey, isNew = true)
|
||||
} else {
|
||||
MessageLogger.logDbSave(messageId, dialogKey, isNew = false)
|
||||
@@ -1405,7 +1464,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
opponentKey = opponentKey,
|
||||
lastMessage = encryptedLastMessage,
|
||||
lastMessageTimestamp = timestamp,
|
||||
unreadCount = unreadCount
|
||||
unreadCount = unreadCount,
|
||||
hasContent = if (encryptedLastMessage.isNotBlank()) 1 else 0
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1638,6 +1698,31 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
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: при
|
||||
* получении attachment с типом AVATAR - сохраняем в avatar_cache
|
||||
|
||||
@@ -17,10 +17,12 @@ object ReleaseNotes {
|
||||
val RELEASE_NOTICE = """
|
||||
Update v$VERSION_PLACEHOLDER
|
||||
|
||||
Защищенные звонки и диагностика E2EE
|
||||
- Обновлен custom WebRTC для Android и исправлена совместимость аудио E2EE с Desktop
|
||||
- Улучшены diagnostics для шифрования звонков (детализация ENC/DEC в crash reports)
|
||||
- В Crash Reports добавлена кнопка копирования полного лога одним действием
|
||||
Оптимизация производительности и стабильности
|
||||
- В release отключена frame-диагностика E2EE (детальные frame-логи оставлены только в debug)
|
||||
- Оптимизирован чат-лист: убрано дублирование collectAsState и вынесены route-компоненты
|
||||
- Ускорены выборки по вложениям: добавлен denormalized attachment type и индекс в БД
|
||||
- Добавлен macrobenchmark-модуль с замерами startup, search и chat list scroll
|
||||
- Исправлено поведение UI в звонке: клавиатура автоматически закрывается при открытии call overlay
|
||||
""".trimIndent()
|
||||
|
||||
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", "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(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
@@ -99,18 +101,47 @@ data class MessageEntity(
|
||||
@ColumnInfo(name = "plain_message")
|
||||
val plainMessage: String, // 🔒 Зашифрованный текст (encryptWithPassword) для хранения в БД
|
||||
@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")
|
||||
val replyToMessageId: String? = null, // ID цитируемого сообщения
|
||||
@ColumnInfo(name = "dialog_key") val dialogKey: String // Ключ диалога для быстрой выборки
|
||||
)
|
||||
|
||||
/** Локальный денормализованный индекс для поиска по сообщениям без повторной дешифровки. */
|
||||
@Entity(
|
||||
tableName = "message_search_index",
|
||||
primaryKeys = ["account", "message_id"],
|
||||
indices =
|
||||
[
|
||||
Index(value = ["account", "timestamp"]),
|
||||
Index(value = ["account", "opponent_key", "timestamp"])]
|
||||
)
|
||||
data class MessageSearchIndexEntity(
|
||||
@ColumnInfo(name = "account") val account: String,
|
||||
@ColumnInfo(name = "message_id") val messageId: String,
|
||||
@ColumnInfo(name = "dialog_key") val dialogKey: String,
|
||||
@ColumnInfo(name = "opponent_key") val opponentKey: String,
|
||||
@ColumnInfo(name = "timestamp") val timestamp: Long,
|
||||
@ColumnInfo(name = "from_me") val fromMe: Int = 0,
|
||||
@ColumnInfo(name = "plain_text") val plainText: String,
|
||||
@ColumnInfo(name = "plain_text_normalized") val plainTextNormalized: String
|
||||
)
|
||||
|
||||
/** Entity для диалогов (кэш последнего сообщения) */
|
||||
@Entity(
|
||||
tableName = "dialogs",
|
||||
indices =
|
||||
[
|
||||
Index(value = ["account", "opponent_key"], unique = true),
|
||||
Index(value = ["account", "last_message_timestamp"])]
|
||||
Index(value = ["account", "last_message_timestamp"]),
|
||||
Index(
|
||||
value =
|
||||
[
|
||||
"account",
|
||||
"i_have_sent",
|
||||
"has_content",
|
||||
"last_message_timestamp"])]
|
||||
)
|
||||
data class DialogEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
@@ -129,6 +160,12 @@ data class DialogEntity(
|
||||
@ColumnInfo(name = "verified") val verified: Int = 0, // Верифицирован
|
||||
@ColumnInfo(name = "i_have_sent", defaultValue = "0")
|
||||
val iHaveSent: Int = 0, // Отправлял ли я сообщения в этот диалог (0/1)
|
||||
@ColumnInfo(name = "has_content", defaultValue = "0")
|
||||
val hasContent: Int = 0, // Есть ли контент в диалоге (0/1)
|
||||
@ColumnInfo(name = "last_message_attachment_type", defaultValue = "-1")
|
||||
val lastMessageAttachmentType: Int = -1, // Денормализованный тип вложения последнего сообщения
|
||||
@ColumnInfo(name = "last_sender_key", defaultValue = "''")
|
||||
val lastSenderKey: String = "", // Для групп: публичный ключ последнего отправителя
|
||||
@ColumnInfo(name = "last_message_from_me", defaultValue = "0")
|
||||
val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
|
||||
@ColumnInfo(name = "last_message_delivered", defaultValue = "0")
|
||||
@@ -174,6 +211,16 @@ interface GroupDao {
|
||||
suspend fun deleteAllByAccount(account: String): Int
|
||||
}
|
||||
|
||||
/** Строка истории звонков (messages + данные собеседника из dialogs) */
|
||||
data class CallHistoryRow(
|
||||
@Embedded val message: MessageEntity,
|
||||
@ColumnInfo(name = "peer_key") val peerKey: String,
|
||||
@ColumnInfo(name = "peer_title") val peerTitle: String?,
|
||||
@ColumnInfo(name = "peer_username") val peerUsername: String?,
|
||||
@ColumnInfo(name = "peer_verified") val peerVerified: Int?,
|
||||
@ColumnInfo(name = "peer_online") val peerOnline: Int?
|
||||
)
|
||||
|
||||
/** DAO для работы с сообщениями */
|
||||
@Dao
|
||||
interface MessageDao {
|
||||
@@ -535,8 +582,7 @@ interface MessageDao {
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
WHERE account = :account
|
||||
AND attachments != '[]'
|
||||
AND attachments LIKE '%"type":0%'
|
||||
AND primary_attachment_type = 0
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
@@ -551,27 +597,123 @@ interface MessageDao {
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
WHERE account = :account
|
||||
AND attachments != '[]'
|
||||
AND attachments LIKE '%"type":2%'
|
||||
AND primary_attachment_type = 2
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
|
||||
|
||||
/** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */
|
||||
/**
|
||||
* 📞 История звонков на основе CALL attachments (type: 4)
|
||||
* LEFT JOIN на dialogs нужен для имени/username/verified без дополнительных запросов.
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
m.*,
|
||||
CASE
|
||||
WHEN m.from_me = 1 THEN m.to_public_key
|
||||
ELSE m.from_public_key
|
||||
END AS peer_key,
|
||||
d.opponent_title AS peer_title,
|
||||
d.opponent_username AS peer_username,
|
||||
d.verified AS peer_verified,
|
||||
d.is_online AS peer_online
|
||||
FROM messages m
|
||||
LEFT JOIN dialogs d
|
||||
ON d.account = m.account
|
||||
AND d.opponent_key = CASE
|
||||
WHEN m.from_me = 1 THEN m.to_public_key
|
||||
ELSE m.from_public_key
|
||||
END
|
||||
WHERE m.account = :account
|
||||
AND m.primary_attachment_type = 4
|
||||
ORDER BY m.timestamp DESC, m.message_id DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
fun getCallHistoryFlow(account: String, limit: Int = 300): Flow<List<CallHistoryRow>>
|
||||
|
||||
/** Пиры, у которых есть call attachments (нужно для пересчета dialogs после удаления). */
|
||||
@Query(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
CASE
|
||||
WHEN from_me = 1 THEN to_public_key
|
||||
ELSE from_public_key
|
||||
END AS peer_key
|
||||
FROM messages
|
||||
WHERE account = :account
|
||||
AND primary_attachment_type = 4
|
||||
"""
|
||||
)
|
||||
suspend fun getCallHistoryPeers(account: String): List<String>
|
||||
|
||||
/** Удалить все call events из messages для аккаунта. */
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM messages
|
||||
WHERE account = :account
|
||||
AND primary_attachment_type = 4
|
||||
"""
|
||||
)
|
||||
suspend fun deleteAllCallMessages(account: String): Int
|
||||
|
||||
/** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
WHERE account = :account
|
||||
AND plain_message != ''
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
)
|
||||
suspend fun getAllMessagesPaged(account: String, limit: Int, offset: Int): List<MessageEntity>
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface MessageSearchIndexDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(items: List<MessageSearchIndexEntity>)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM message_search_index
|
||||
WHERE account = :account
|
||||
AND plain_text_normalized LIKE '%' || :queryNormalized || '%'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
suspend fun search(
|
||||
account: String,
|
||||
queryNormalized: String,
|
||||
limit: Int,
|
||||
offset: Int = 0
|
||||
): List<MessageSearchIndexEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT m.* FROM messages m
|
||||
LEFT JOIN message_search_index s
|
||||
ON s.account = m.account
|
||||
AND s.message_id = m.message_id
|
||||
WHERE m.account = :account
|
||||
AND m.plain_message != ''
|
||||
AND s.message_id IS NULL
|
||||
ORDER BY m.timestamp DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
suspend fun getUnindexedMessages(account: String, limit: Int): List<MessageEntity>
|
||||
|
||||
@Query("DELETE FROM message_search_index WHERE account = :account")
|
||||
suspend fun deleteByAccount(account: String): Int
|
||||
}
|
||||
|
||||
/** DAO для работы с диалогами */
|
||||
@Dao
|
||||
interface DialogDao {
|
||||
@@ -593,7 +735,7 @@ interface DialogDao {
|
||||
OR opponent_key = '0x000000000000000000000000000000000000000001'
|
||||
OR opponent_key = '0x000000000000000000000000000000000000000002'
|
||||
)
|
||||
AND (last_message != '' OR last_message_attachments != '[]')
|
||||
AND has_content = 1
|
||||
ORDER BY last_message_timestamp DESC
|
||||
LIMIT 30
|
||||
"""
|
||||
@@ -610,7 +752,7 @@ interface DialogDao {
|
||||
OR opponent_key = '0x000000000000000000000000000000000000000001'
|
||||
OR opponent_key = '0x000000000000000000000000000000000000000002'
|
||||
)
|
||||
AND (last_message != '' OR last_message_attachments != '[]')
|
||||
AND has_content = 1
|
||||
ORDER BY last_message_timestamp DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
@@ -628,7 +770,7 @@ interface DialogDao {
|
||||
AND i_have_sent = 0
|
||||
AND opponent_key != '0x000000000000000000000000000000000000000001'
|
||||
AND opponent_key != '0x000000000000000000000000000000000000000002'
|
||||
AND (last_message != '' OR last_message_attachments != '[]')
|
||||
AND has_content = 1
|
||||
ORDER BY last_message_timestamp DESC
|
||||
LIMIT 30
|
||||
"""
|
||||
@@ -643,7 +785,7 @@ interface DialogDao {
|
||||
AND i_have_sent = 0
|
||||
AND opponent_key != '0x000000000000000000000000000000000000000001'
|
||||
AND opponent_key != '0x000000000000000000000000000000000000000002'
|
||||
AND (last_message != '' OR last_message_attachments != '[]')
|
||||
AND has_content = 1
|
||||
"""
|
||||
)
|
||||
fun getRequestsCountFlow(account: String): Flow<Int>
|
||||
@@ -656,7 +798,7 @@ interface DialogDao {
|
||||
@Query("""
|
||||
SELECT * FROM dialogs
|
||||
WHERE account = :account
|
||||
AND (last_message != '' OR last_message_attachments != '[]')
|
||||
AND has_content = 1
|
||||
AND opponent_key NOT LIKE '#group:%'
|
||||
AND (
|
||||
opponent_title = ''
|
||||
@@ -687,7 +829,8 @@ interface DialogDao {
|
||||
"""
|
||||
UPDATE dialogs SET
|
||||
last_message = :lastMessage,
|
||||
last_message_timestamp = :timestamp
|
||||
last_message_timestamp = :timestamp,
|
||||
has_content = CASE WHEN TRIM(:lastMessage) != '' THEN 1 ELSE has_content END
|
||||
WHERE account = :account AND opponent_key = :opponentKey
|
||||
"""
|
||||
)
|
||||
@@ -916,6 +1059,16 @@ interface DialogDao {
|
||||
val hasSent = hasSentByDialogKey(account, dialogKey)
|
||||
|
||||
// 5. Один INSERT OR REPLACE с вычисленными данными
|
||||
val hasContent =
|
||||
if (
|
||||
lastMsg.plainMessage.isNotBlank() ||
|
||||
(lastMsg.attachments.isNotBlank() &&
|
||||
lastMsg.attachments.trim() != "[]")
|
||||
) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
insertDialog(
|
||||
DialogEntity(
|
||||
id = existing?.id ?: 0,
|
||||
@@ -931,6 +1084,9 @@ interface DialogDao {
|
||||
verified = existing?.verified ?: 0,
|
||||
// Desktop parity: request flag is always derived from message history.
|
||||
iHaveSent = if (hasSent || isSystemDialog) 1 else 0,
|
||||
hasContent = hasContent,
|
||||
lastMessageAttachmentType = lastMsg.primaryAttachmentType,
|
||||
lastSenderKey = lastMsg.fromPublicKey,
|
||||
lastMessageFromMe = lastMsg.fromMe,
|
||||
lastMessageDelivered = if (lastMsg.fromMe == 1) lastMsg.delivered else 0,
|
||||
lastMessageRead = if (lastMsg.fromMe == 1) lastMsg.read else 0,
|
||||
@@ -950,6 +1106,16 @@ interface DialogDao {
|
||||
|
||||
val lastMsg = getLastMessageByDialogKey(account, dialogKey) ?: return
|
||||
val existing = getDialog(account, account)
|
||||
val hasContent =
|
||||
if (
|
||||
lastMsg.plainMessage.isNotBlank() ||
|
||||
(lastMsg.attachments.isNotBlank() &&
|
||||
lastMsg.attachments.trim() != "[]")
|
||||
) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
insertDialog(
|
||||
DialogEntity(
|
||||
@@ -965,6 +1131,9 @@ interface DialogDao {
|
||||
lastSeen = existing?.lastSeen ?: 0,
|
||||
verified = existing?.verified ?: 0,
|
||||
iHaveSent = 1,
|
||||
hasContent = hasContent,
|
||||
lastMessageAttachmentType = lastMsg.primaryAttachmentType,
|
||||
lastSenderKey = lastMsg.fromPublicKey,
|
||||
lastMessageFromMe = 1,
|
||||
lastMessageDelivered = 1,
|
||||
lastMessageRead = 1,
|
||||
|
||||
@@ -12,13 +12,14 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
[
|
||||
EncryptedAccountEntity::class,
|
||||
MessageEntity::class,
|
||||
MessageSearchIndexEntity::class,
|
||||
DialogEntity::class,
|
||||
BlacklistEntity::class,
|
||||
AvatarCacheEntity::class,
|
||||
AccountSyncTimeEntity::class,
|
||||
GroupEntity::class,
|
||||
PinnedMessageEntity::class],
|
||||
version = 14,
|
||||
version = 17,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class RosettaDatabase : RoomDatabase() {
|
||||
@@ -30,6 +31,7 @@ abstract class RosettaDatabase : RoomDatabase() {
|
||||
abstract fun syncTimeDao(): SyncTimeDao
|
||||
abstract fun groupDao(): GroupDao
|
||||
abstract fun pinnedMessageDao(): PinnedMessageDao
|
||||
abstract fun messageSearchIndexDao(): MessageSearchIndexDao
|
||||
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: RosettaDatabase? = null
|
||||
@@ -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 {
|
||||
return INSTANCE
|
||||
?: synchronized(this) {
|
||||
@@ -224,7 +374,10 @@ abstract class RosettaDatabase : RoomDatabase() {
|
||||
MIGRATION_10_11,
|
||||
MIGRATION_11_12,
|
||||
MIGRATION_12_13,
|
||||
MIGRATION_13_14
|
||||
MIGRATION_13_14,
|
||||
MIGRATION_14_15,
|
||||
MIGRATION_15_16,
|
||||
MIGRATION_16_17
|
||||
)
|
||||
.fallbackToDestructiveMigration() // Для разработки - только
|
||||
// если миграция не
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.rosetta.messenger.network
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.util.Log
|
||||
import com.rosetta.messenger.BuildConfig
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
@@ -199,7 +200,7 @@ object CallManager {
|
||||
updateState {
|
||||
it.copy(
|
||||
phase = CallPhase.OUTGOING,
|
||||
statusText = "Calling..."
|
||||
statusText = "Calling"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -872,13 +873,15 @@ object CallManager {
|
||||
}
|
||||
sharedKeyBytes = keyBytes.copyOf(32)
|
||||
breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}")
|
||||
// Open native diagnostics file for frame-level logging
|
||||
try {
|
||||
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
|
||||
XChaCha20E2EE.nativeOpenDiagFile(diagPath)
|
||||
} catch (_: Throwable) {}
|
||||
// Frame-level diagnostics are enabled only for debug builds.
|
||||
if (BuildConfig.DEBUG) {
|
||||
try {
|
||||
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
|
||||
XChaCha20E2EE.nativeOpenDiagFile(diagPath)
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
// If sender track already exists, bind encryptor now.
|
||||
val existingSender =
|
||||
pendingAudioSenderForE2ee
|
||||
|
||||
@@ -32,7 +32,7 @@ class Protocol(
|
||||
private const val TAG = "RosettaProtocol"
|
||||
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
|
||||
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
|
||||
private const val MIN_PACKET_ID_BITS = 16 // Stream.readInt16() reads exactly 16 bits
|
||||
private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each)
|
||||
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
|
||||
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
|
||||
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
|
||||
|
||||
@@ -1,332 +1,163 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
/**
|
||||
* Binary stream for protocol packets.
|
||||
* Ported from desktop/dev stream.ts implementation.
|
||||
* Binary stream for protocol packets
|
||||
* Matches the React Native implementation exactly
|
||||
*/
|
||||
class Stream(stream: ByteArray = ByteArray(0)) {
|
||||
private var stream: ByteArray
|
||||
private var readPointer = 0 // bits
|
||||
private var writePointer = 0 // bits
|
||||
|
||||
private var _stream = mutableListOf<Int>()
|
||||
private var _readPointer = 0
|
||||
private var _writePointer = 0
|
||||
|
||||
init {
|
||||
if (stream.isEmpty()) {
|
||||
this.stream = ByteArray(0)
|
||||
} else {
|
||||
this.stream = stream.copyOf()
|
||||
this.writePointer = this.stream.size shl 3
|
||||
}
|
||||
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
|
||||
}
|
||||
|
||||
|
||||
fun getStream(): ByteArray {
|
||||
return stream.copyOf(length())
|
||||
return _stream.map { it.toByte() }.toByteArray()
|
||||
}
|
||||
|
||||
fun setStream(stream: ByteArray = ByteArray(0)) {
|
||||
if (stream.isEmpty()) {
|
||||
this.stream = ByteArray(0)
|
||||
this.readPointer = 0
|
||||
this.writePointer = 0
|
||||
return
|
||||
fun getReadPointerBits(): Int = _readPointer
|
||||
|
||||
fun getTotalBits(): Int = _stream.size * 8
|
||||
|
||||
fun getRemainingBits(): Int = getTotalBits() - _readPointer
|
||||
|
||||
fun hasRemainingBits(): Boolean = _readPointer < getTotalBits()
|
||||
|
||||
fun setStream(stream: ByteArray) {
|
||||
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
|
||||
_readPointer = 0
|
||||
}
|
||||
|
||||
fun writeInt8(value: Int) {
|
||||
val negationBit = if (value < 0) 1 else 0
|
||||
val int8Value = Math.abs(value) and 0xFF
|
||||
|
||||
ensureCapacity(_writePointer shr 3)
|
||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (negationBit shl (7 - (_writePointer and 7)))
|
||||
_writePointer++
|
||||
|
||||
for (i in 0 until 8) {
|
||||
val bit = (int8Value shr (7 - i)) and 1
|
||||
ensureCapacity(_writePointer shr 3)
|
||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
|
||||
_writePointer++
|
||||
}
|
||||
this.stream = stream.copyOf()
|
||||
this.readPointer = 0
|
||||
this.writePointer = this.stream.size shl 3
|
||||
}
|
||||
|
||||
fun getBuffer(): ByteArray = getStream()
|
||||
|
||||
fun isEmpty(): Boolean = writePointer == 0
|
||||
|
||||
fun length(): Int = (writePointer + 7) shr 3
|
||||
|
||||
fun getReadPointerBits(): Int = readPointer
|
||||
|
||||
fun getTotalBits(): Int = writePointer
|
||||
|
||||
fun getRemainingBits(): Int = writePointer - readPointer
|
||||
|
||||
fun hasRemainingBits(): Boolean = readPointer < writePointer
|
||||
|
||||
|
||||
fun readInt8(): Int {
|
||||
var value = 0
|
||||
val negationBit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||
_readPointer++
|
||||
|
||||
for (i in 0 until 8) {
|
||||
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||
value = value or (bit shl (7 - i))
|
||||
_readPointer++
|
||||
}
|
||||
|
||||
return if (negationBit == 1) -value else value
|
||||
}
|
||||
|
||||
fun writeBit(value: Int) {
|
||||
writeBits((value and 1).toULong(), 1)
|
||||
val bit = value and 1
|
||||
ensureCapacity(_writePointer shr 3)
|
||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
|
||||
_writePointer++
|
||||
}
|
||||
|
||||
fun readBit(): Int = readBits(1).toInt()
|
||||
|
||||
|
||||
fun readBit(): Int {
|
||||
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||
_readPointer++
|
||||
return bit
|
||||
}
|
||||
|
||||
fun writeBoolean(value: Boolean) {
|
||||
writeBit(if (value) 1 else 0)
|
||||
}
|
||||
|
||||
fun readBoolean(): Boolean = readBit() == 1
|
||||
|
||||
fun writeByte(value: Int) {
|
||||
writeUInt8(value and 0xFF)
|
||||
|
||||
fun readBoolean(): Boolean {
|
||||
return readBit() == 1
|
||||
}
|
||||
|
||||
fun readByte(): Int {
|
||||
val value = readUInt8()
|
||||
return if (value >= 0x80) value - 0x100 else value
|
||||
}
|
||||
|
||||
fun writeUInt8(value: Int) {
|
||||
val v = value and 0xFF
|
||||
|
||||
if ((writePointer and 7) == 0) {
|
||||
reserveBits(8)
|
||||
stream[writePointer shr 3] = v.toByte()
|
||||
writePointer += 8
|
||||
return
|
||||
}
|
||||
|
||||
writeBits(v.toULong(), 8)
|
||||
}
|
||||
|
||||
fun readUInt8(): Int {
|
||||
if (remainingBits() < 8L) {
|
||||
throw IllegalStateException("Not enough bits to read UInt8")
|
||||
}
|
||||
|
||||
if ((readPointer and 7) == 0) {
|
||||
val value = stream[readPointer shr 3].toInt() and 0xFF
|
||||
readPointer += 8
|
||||
return value
|
||||
}
|
||||
|
||||
return readBits(8).toInt()
|
||||
}
|
||||
|
||||
fun writeInt8(value: Int) {
|
||||
writeUInt8(value)
|
||||
}
|
||||
|
||||
fun readInt8(): Int {
|
||||
val value = readUInt8()
|
||||
return if (value >= 0x80) value - 0x100 else value
|
||||
}
|
||||
|
||||
fun writeUInt16(value: Int) {
|
||||
val v = value and 0xFFFF
|
||||
writeUInt8((v ushr 8) and 0xFF)
|
||||
writeUInt8(v and 0xFF)
|
||||
}
|
||||
|
||||
fun readUInt16(): Int {
|
||||
val hi = readUInt8()
|
||||
val lo = readUInt8()
|
||||
return (hi shl 8) or lo
|
||||
}
|
||||
|
||||
|
||||
fun writeInt16(value: Int) {
|
||||
writeUInt16(value)
|
||||
writeInt8(value shr 8)
|
||||
writeInt8(value and 0xFF)
|
||||
}
|
||||
|
||||
|
||||
fun readInt16(): Int {
|
||||
val value = readUInt16()
|
||||
return if (value >= 0x8000) value - 0x10000 else value
|
||||
val high = readInt8() shl 8
|
||||
return high or readInt8()
|
||||
}
|
||||
|
||||
fun writeUInt32(value: Long) {
|
||||
if (value < 0L || value > 0xFFFF_FFFFL) {
|
||||
throw IllegalArgumentException("UInt32 out of range: $value")
|
||||
}
|
||||
|
||||
writeUInt8(((value ushr 24) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 16) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 8) and 0xFF).toInt())
|
||||
writeUInt8((value and 0xFF).toInt())
|
||||
}
|
||||
|
||||
fun readUInt32(): Long {
|
||||
val b1 = readUInt8().toLong()
|
||||
val b2 = readUInt8().toLong()
|
||||
val b3 = readUInt8().toLong()
|
||||
val b4 = readUInt8().toLong()
|
||||
return ((b1 shl 24) or (b2 shl 16) or (b3 shl 8) or b4) and 0xFFFF_FFFFL
|
||||
}
|
||||
|
||||
|
||||
fun writeInt32(value: Int) {
|
||||
writeUInt32(value.toLong() and 0xFFFF_FFFFL)
|
||||
writeInt16(value shr 16)
|
||||
writeInt16(value and 0xFFFF)
|
||||
}
|
||||
|
||||
fun readInt32(): Int = readUInt32().toInt()
|
||||
|
||||
fun writeUInt64(value: ULong) {
|
||||
writeUInt8(((value shr 56) and 0xFFu).toInt())
|
||||
writeUInt8(((value shr 48) and 0xFFu).toInt())
|
||||
writeUInt8(((value shr 40) and 0xFFu).toInt())
|
||||
writeUInt8(((value shr 32) and 0xFFu).toInt())
|
||||
writeUInt8(((value shr 24) and 0xFFu).toInt())
|
||||
writeUInt8(((value shr 16) and 0xFFu).toInt())
|
||||
writeUInt8(((value shr 8) and 0xFFu).toInt())
|
||||
writeUInt8((value and 0xFFu).toInt())
|
||||
|
||||
fun readInt32(): Int {
|
||||
val high = readInt16() shl 16
|
||||
return high or readInt16()
|
||||
}
|
||||
|
||||
fun readUInt64(): ULong {
|
||||
val high = readUInt32().toULong()
|
||||
val low = readUInt32().toULong()
|
||||
|
||||
fun writeInt64(value: Long) {
|
||||
val high = (value shr 32).toInt()
|
||||
val low = (value and 0xFFFFFFFF).toInt()
|
||||
writeInt32(high)
|
||||
writeInt32(low)
|
||||
}
|
||||
|
||||
fun readInt64(): Long {
|
||||
val high = readInt32().toLong()
|
||||
val low = (readInt32().toLong() and 0xFFFFFFFFL)
|
||||
return (high shl 32) or low
|
||||
}
|
||||
|
||||
fun writeInt64(value: Long) {
|
||||
writeUInt64(value.toULong())
|
||||
}
|
||||
|
||||
fun readInt64(): Long = readUInt64().toLong()
|
||||
|
||||
fun writeFloat32(value: Float) {
|
||||
val bits = value.toRawBits().toLong() and 0xFFFF_FFFFL
|
||||
writeUInt32(bits)
|
||||
}
|
||||
|
||||
fun readFloat32(): Float {
|
||||
val bits = readUInt32().toInt()
|
||||
return Float.fromBits(bits)
|
||||
}
|
||||
|
||||
fun writeString(value: String?) {
|
||||
val str = value ?: ""
|
||||
writeUInt32(str.length.toLong())
|
||||
|
||||
if (str.isEmpty()) return
|
||||
|
||||
reserveBits(str.length.toLong() * 16L)
|
||||
for (i in str.indices) {
|
||||
writeUInt16(str[i].code and 0xFFFF)
|
||||
|
||||
fun writeString(value: String) {
|
||||
writeInt32(value.length)
|
||||
for (char in value) {
|
||||
writeInt16(char.code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun readString(): String {
|
||||
val len = readUInt32()
|
||||
if (len > Int.MAX_VALUE.toLong()) {
|
||||
throw IllegalStateException("String length too large: $len")
|
||||
val length = readInt32()
|
||||
// Desktop parity + safety: don't trust malformed string length.
|
||||
val bytesAvailable = _stream.size - (_readPointer shr 3)
|
||||
if (length < 0 || (length.toLong() * 2L) > bytesAvailable.toLong()) {
|
||||
android.util.Log.w(
|
||||
"RosettaStream",
|
||||
"readString invalid length=$length, bytesAvailable=$bytesAvailable, readPointer=$_readPointer"
|
||||
)
|
||||
return ""
|
||||
}
|
||||
|
||||
val requiredBits = len * 16L
|
||||
if (requiredBits > remainingBits()) {
|
||||
throw IllegalStateException("Not enough bits to read string")
|
||||
val sb = StringBuilder()
|
||||
for (i in 0 until length) {
|
||||
sb.append(readInt16().toChar())
|
||||
}
|
||||
|
||||
val chars = CharArray(len.toInt())
|
||||
for (i in chars.indices) {
|
||||
chars[i] = readUInt16().toChar()
|
||||
}
|
||||
return String(chars)
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun writeBytes(value: ByteArray?) {
|
||||
val bytes = value ?: ByteArray(0)
|
||||
writeUInt32(bytes.size.toLong())
|
||||
if (bytes.isEmpty()) return
|
||||
|
||||
reserveBits(bytes.size.toLong() * 8L)
|
||||
|
||||
if ((writePointer and 7) == 0) {
|
||||
val byteIndex = writePointer shr 3
|
||||
ensureCapacity(byteIndex + bytes.size - 1)
|
||||
System.arraycopy(bytes, 0, stream, byteIndex, bytes.size)
|
||||
writePointer += bytes.size shl 3
|
||||
return
|
||||
}
|
||||
|
||||
for (b in bytes) {
|
||||
writeUInt8(b.toInt() and 0xFF)
|
||||
|
||||
fun writeBytes(value: ByteArray) {
|
||||
writeInt32(value.size)
|
||||
for (byte in value) {
|
||||
writeInt8(byte.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun readBytes(): ByteArray {
|
||||
val len = readUInt32()
|
||||
if (len == 0L) return ByteArray(0)
|
||||
if (len > Int.MAX_VALUE.toLong()) return ByteArray(0)
|
||||
|
||||
val requiredBits = len * 8L
|
||||
if (requiredBits > remainingBits()) {
|
||||
return ByteArray(0)
|
||||
val length = readInt32()
|
||||
val bytes = ByteArray(length)
|
||||
for (i in 0 until length) {
|
||||
bytes[i] = readInt8().toByte()
|
||||
}
|
||||
|
||||
val out = ByteArray(len.toInt())
|
||||
|
||||
if ((readPointer and 7) == 0) {
|
||||
val byteIndex = readPointer shr 3
|
||||
System.arraycopy(stream, byteIndex, out, 0, out.size)
|
||||
readPointer += out.size shl 3
|
||||
return out
|
||||
}
|
||||
|
||||
for (i in out.indices) {
|
||||
out[i] = readUInt8().toByte()
|
||||
}
|
||||
return out
|
||||
return bytes
|
||||
}
|
||||
|
||||
private fun remainingBits(): Long = (writePointer - readPointer).toLong()
|
||||
|
||||
private fun writeBits(value: ULong, bits: Int) {
|
||||
if (bits <= 0) return
|
||||
|
||||
reserveBits(bits.toLong())
|
||||
|
||||
for (i in bits - 1 downTo 0) {
|
||||
val bit = ((value shr i) and 1u).toInt()
|
||||
val byteIndex = writePointer shr 3
|
||||
val shift = 7 - (writePointer and 7)
|
||||
|
||||
if (bit == 1) {
|
||||
stream[byteIndex] = (stream[byteIndex].toInt() or (1 shl shift)).toByte()
|
||||
} else {
|
||||
stream[byteIndex] = (stream[byteIndex].toInt() and (1 shl shift).inv()).toByte()
|
||||
}
|
||||
|
||||
writePointer++
|
||||
}
|
||||
}
|
||||
|
||||
private fun readBits(bits: Int): ULong {
|
||||
if (bits <= 0) return 0u
|
||||
if (remainingBits() < bits.toLong()) {
|
||||
throw IllegalStateException("Not enough bits to read")
|
||||
}
|
||||
|
||||
var value = 0uL
|
||||
repeat(bits) {
|
||||
val bit = (stream[readPointer shr 3].toInt() ushr (7 - (readPointer and 7))) and 1
|
||||
value = (value shl 1) or bit.toULong()
|
||||
readPointer++
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private fun reserveBits(bitsToWrite: Long) {
|
||||
if (bitsToWrite <= 0L) return
|
||||
|
||||
val lastBitIndex = writePointer.toLong() + bitsToWrite - 1L
|
||||
if (lastBitIndex < 0L) {
|
||||
throw IllegalStateException("Bit index overflow")
|
||||
}
|
||||
|
||||
val byteIndex = lastBitIndex ushr 3
|
||||
if (byteIndex > Int.MAX_VALUE.toLong()) {
|
||||
throw IllegalStateException("Stream too large")
|
||||
}
|
||||
|
||||
ensureCapacity(byteIndex.toInt())
|
||||
}
|
||||
|
||||
|
||||
private fun ensureCapacity(index: Int) {
|
||||
val requiredSize = index + 1
|
||||
if (requiredSize <= stream.size) return
|
||||
|
||||
var newSize = if (stream.isEmpty()) 32 else stream.size
|
||||
while (newSize < requiredSize) {
|
||||
if (newSize > (Int.MAX_VALUE shr 1)) {
|
||||
newSize = requiredSize
|
||||
break
|
||||
}
|
||||
newSize = newSize shl 1
|
||||
while (_stream.size <= index) {
|
||||
_stream.add(0)
|
||||
}
|
||||
|
||||
val next = ByteArray(newSize)
|
||||
System.arraycopy(stream, 0, next, 0, stream.size)
|
||||
stream = next
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +295,8 @@ fun ChatDetailScreen(
|
||||
isDarkTheme: Boolean,
|
||||
chatWallpaperId: String = "",
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
onImageViewerChanged: (Boolean) -> Unit = {}
|
||||
onImageViewerChanged: (Boolean) -> Unit = {},
|
||||
isCallActive: Boolean = false
|
||||
) {
|
||||
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
|
||||
val context = LocalContext.current
|
||||
@@ -381,6 +382,13 @@ fun ChatDetailScreen(
|
||||
// Логирование изменений selection mode
|
||||
LaunchedEffect(isSelectionMode, selectedMessages.size) {}
|
||||
|
||||
// Сброс выделения при начале звонка
|
||||
LaunchedEffect(isCallActive) {
|
||||
if (isCallActive) {
|
||||
selectedMessages = emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 Backup: если клавиатура ещё открыта когда selection mode активировался
|
||||
// (клавиатура уже должна быть закрыта в onLongClick, это только backup)
|
||||
LaunchedEffect(isSelectionMode) {
|
||||
@@ -2974,7 +2982,7 @@ fun ChatDetailScreen(
|
||||
avatarRepository =
|
||||
avatarRepository,
|
||||
onLongClick = {
|
||||
if (simplePickerPreviewUri != null) {
|
||||
if (simplePickerPreviewUri != null || isCallActive) {
|
||||
return@MessageBubble
|
||||
}
|
||||
// 📳 Haptic feedback при долгом нажатии
|
||||
@@ -3017,7 +3025,7 @@ fun ChatDetailScreen(
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
if (simplePickerPreviewUri != null) {
|
||||
if (simplePickerPreviewUri != null || isCallActive) {
|
||||
return@MessageBubble
|
||||
}
|
||||
if (shouldIgnoreTapAfterLongPress(
|
||||
@@ -3039,12 +3047,17 @@ fun ChatDetailScreen(
|
||||
message.attachments.all {
|
||||
it.type == AttachmentType.IMAGE
|
||||
}
|
||||
val isCallMessage =
|
||||
message.attachments.isNotEmpty() &&
|
||||
message.attachments.all {
|
||||
it.type == AttachmentType.CALL
|
||||
}
|
||||
if (isSelectionMode) {
|
||||
toggleMessageSelection(
|
||||
selectionKey,
|
||||
!hasAvatar
|
||||
)
|
||||
} else if (!hasAvatar && !isPhotoOnly) {
|
||||
} else if (!hasAvatar && (!isPhotoOnly || isCallMessage)) {
|
||||
// 💬 Tap = context menu
|
||||
contextMenuMessage = message
|
||||
showContextMenu = true
|
||||
|
||||
@@ -98,6 +98,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val database = RosettaDatabase.getDatabase(application)
|
||||
private val dialogDao = database.dialogDao()
|
||||
private val messageDao = database.messageDao()
|
||||
private val searchIndexDao = database.messageSearchIndexDao()
|
||||
private val groupDao = database.groupDao()
|
||||
private val pinnedMessageDao = database.pinnedMessageDao()
|
||||
|
||||
@@ -126,6 +127,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// UI State
|
||||
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
||||
@Volatile private var normalizedMessagesDescCache: List<ChatMessage> = emptyList()
|
||||
|
||||
/**
|
||||
* Pre-computed messages with date headers — runs dedup + sort on Dispatchers.Default. Replaces
|
||||
@@ -143,20 +145,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
.debounce(16) // coalesce rapid updates (1 frame)
|
||||
.mapLatest { rawMessages ->
|
||||
withContext(Dispatchers.Default) {
|
||||
val unique = rawMessages.distinctBy { it.id }
|
||||
val sorted = unique.sortedWith(chatMessageDescComparator)
|
||||
val result = ArrayList<Pair<ChatMessage, Boolean>>(sorted.size)
|
||||
var prevDateStr: String? = null
|
||||
for (i in sorted.indices) {
|
||||
val msg = sorted[i]
|
||||
val dateStr = _dateFmt.format(msg.timestamp)
|
||||
val nextMsg = sorted.getOrNull(i + 1)
|
||||
val nextDateStr = nextMsg?.let { _dateFmt.format(it.timestamp) }
|
||||
val showDate = nextDateStr == null || nextDateStr != dateStr
|
||||
result.add(msg to showDate)
|
||||
prevDateStr = dateStr
|
||||
}
|
||||
result as List<Pair<ChatMessage, Boolean>>
|
||||
val normalized =
|
||||
normalizeMessagesDescendingIncremental(
|
||||
previous = normalizedMessagesDescCache,
|
||||
incoming = rawMessages
|
||||
)
|
||||
normalizedMessagesDescCache = normalized
|
||||
buildMessagesWithDateHeaders(normalized)
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
@@ -234,6 +229,98 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private fun sortMessagesAscending(messages: List<ChatMessage>): List<ChatMessage> =
|
||||
messages.sortedWith(chatMessageAscComparator)
|
||||
|
||||
private fun isSortedDescending(messages: List<ChatMessage>): Boolean {
|
||||
if (messages.size < 2) return true
|
||||
for (i in 0 until messages.lastIndex) {
|
||||
if (chatMessageDescComparator.compare(messages[i], messages[i + 1]) > 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun insertIntoSortedDescending(
|
||||
existing: List<ChatMessage>,
|
||||
message: ChatMessage
|
||||
): List<ChatMessage> {
|
||||
if (existing.isEmpty()) return listOf(message)
|
||||
val result = ArrayList<ChatMessage>(existing.size + 1)
|
||||
var inserted = false
|
||||
existing.forEach { current ->
|
||||
if (!inserted && chatMessageDescComparator.compare(message, current) <= 0) {
|
||||
result.add(message)
|
||||
inserted = true
|
||||
}
|
||||
result.add(current)
|
||||
}
|
||||
if (!inserted) result.add(message)
|
||||
return result
|
||||
}
|
||||
|
||||
private fun normalizeMessagesDescendingIncremental(
|
||||
previous: List<ChatMessage>,
|
||||
incoming: List<ChatMessage>
|
||||
): List<ChatMessage> {
|
||||
if (incoming.isEmpty()) return emptyList()
|
||||
|
||||
val dedupedById = LinkedHashMap<String, ChatMessage>(incoming.size)
|
||||
incoming.forEach { message -> dedupedById[message.id] = message }
|
||||
|
||||
if (previous.isNotEmpty() && dedupedById.size == previous.size) {
|
||||
var unchanged = true
|
||||
previous.forEach { message ->
|
||||
if (dedupedById[message.id] != message) {
|
||||
unchanged = false
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
if (unchanged && isSortedDescending(previous)) {
|
||||
return previous
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isNotEmpty() && dedupedById.size == previous.size + 1) {
|
||||
val previousIds = HashSet<String>(previous.size)
|
||||
previous.forEach { previousIds.add(it.id) }
|
||||
|
||||
val addedIds = dedupedById.keys.filter { it !in previousIds }
|
||||
if (addedIds.size == 1) {
|
||||
var previousUnchanged = true
|
||||
previous.forEach { message ->
|
||||
if (dedupedById[message.id] != message) {
|
||||
previousUnchanged = false
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
if (previousUnchanged) {
|
||||
val addedMessage = dedupedById.getValue(addedIds.first())
|
||||
return insertIntoSortedDescending(previous, addedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val normalized = ArrayList<ChatMessage>(dedupedById.values)
|
||||
if (!isSortedDescending(normalized)) {
|
||||
normalized.sortWith(chatMessageDescComparator)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private fun buildMessagesWithDateHeaders(
|
||||
sortedMessagesDesc: List<ChatMessage>
|
||||
): List<Pair<ChatMessage, Boolean>> {
|
||||
val result = ArrayList<Pair<ChatMessage, Boolean>>(sortedMessagesDesc.size)
|
||||
for (i in sortedMessagesDesc.indices) {
|
||||
val msg = sortedMessagesDesc[i]
|
||||
val dateStr = _dateFmt.format(msg.timestamp)
|
||||
val nextMsg = sortedMessagesDesc.getOrNull(i + 1)
|
||||
val nextDateStr = nextMsg?.let { _dateFmt.format(it.timestamp) }
|
||||
val showDate = nextDateStr == null || nextDateStr != dateStr
|
||||
result.add(msg to showDate)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun latestIncomingMessage(messages: List<ChatMessage>): ChatMessage? =
|
||||
messages.asSequence().filter { !it.isOutgoing }.maxWithOrNull(chatMessageAscComparator)
|
||||
|
||||
@@ -4641,14 +4728,42 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
encryptedPlainMessage, // 🔒 Зашифрованный текст для хранения в
|
||||
// БД
|
||||
attachments = attachmentsJson,
|
||||
primaryAttachmentType =
|
||||
resolvePrimaryAttachmentTypeFromJson(attachmentsJson),
|
||||
replyToMessageId = null,
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
|
||||
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) {}
|
||||
}
|
||||
|
||||
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() {
|
||||
_opponentTyping.value = true
|
||||
// Отменяем предыдущий таймер, чтобы избежать race condition
|
||||
|
||||
@@ -63,10 +63,12 @@ import com.rosetta.messenger.data.EncryptedAccount
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.data.RecentSearchesManager
|
||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.DeviceEntry
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.chats.calls.CallsHistoryScreen
|
||||
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
|
||||
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||
@@ -260,6 +262,7 @@ fun ChatsListScreen(
|
||||
onRequestsClick: () -> Unit = {},
|
||||
onNewChat: () -> Unit,
|
||||
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
||||
onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
||||
backgroundBlurColorId: String = "avatar",
|
||||
pinnedChats: Set<String> = emptySet(),
|
||||
onTogglePin: (String) -> Unit = {},
|
||||
@@ -283,9 +286,6 @@ fun ChatsListScreen(
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
val sduUpdateState by UpdateManager.updateState.collectAsState()
|
||||
val sduDownloadProgress by UpdateManager.downloadProgress.collectAsState()
|
||||
val sduDebugLogs by UpdateManager.debugLogs.collectAsState()
|
||||
var showSduLogs by remember { mutableStateOf(false) }
|
||||
val themeRevealRadius = remember { Animatable(0f) }
|
||||
var rootSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
var themeToggleCenterInRoot by remember { mutableStateOf<Offset?>(null) }
|
||||
@@ -294,73 +294,6 @@ fun ChatsListScreen(
|
||||
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
|
||||
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||
|
||||
// ═══════════════ SDU Debug Log Dialog ═══════════════
|
||||
if (showSduLogs) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showSduLogs = false },
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("SDU Logs", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
"state: ${sduUpdateState::class.simpleName}",
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(sduDebugLogs.size) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 400.dp)
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
if (sduDebugLogs.isEmpty()) {
|
||||
Text(
|
||||
"Нет логов. SDU ещё не инициализирован\nили пакет 0x0A не пришёл.",
|
||||
fontSize = 13.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
} else {
|
||||
sduDebugLogs.forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = when {
|
||||
"ERROR" in line || "EXCEPTION" in line -> Color(0xFFFF5555)
|
||||
"WARNING" in line -> Color(0xFFFFAA33)
|
||||
"State ->" in line -> Color(0xFF55BB55)
|
||||
else -> if (isDarkTheme) Color(0xFFCCCCCC) else Color(0xFF333333)
|
||||
},
|
||||
modifier = Modifier.padding(vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row {
|
||||
TextButton(onClick = {
|
||||
// Retry: force re-request SDU
|
||||
UpdateManager.requestSduServer()
|
||||
}) {
|
||||
Text("Retry SDU")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
TextButton(onClick = { showSduLogs = false }) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun startThemeReveal() {
|
||||
if (themeRevealActive) {
|
||||
return
|
||||
@@ -477,6 +410,7 @@ fun ChatsListScreen(
|
||||
}
|
||||
.sortedByDescending { it.progress }
|
||||
}
|
||||
val database = remember(context) { RosettaDatabase.getDatabase(context) }
|
||||
val activeFileDownloads = remember(accountFileDownloads) {
|
||||
accountFileDownloads.filter {
|
||||
it.status == com.rosetta.messenger.network.FileDownloadStatus.QUEUED ||
|
||||
@@ -520,14 +454,19 @@ fun ChatsListScreen(
|
||||
|
||||
// 📬 Requests screen state
|
||||
var showRequestsScreen by remember { mutableStateOf(false) }
|
||||
var showCallsScreen by remember { mutableStateOf(false) }
|
||||
var showCallsMenu by remember { mutableStateOf(false) }
|
||||
var showDownloadsScreen by remember { mutableStateOf(false) }
|
||||
var isInlineRequestsTransitionLocked by remember { mutableStateOf(false) }
|
||||
var isInlineCallsTransitionLocked by remember { mutableStateOf(false) }
|
||||
var isRequestsRouteTapLocked by remember { mutableStateOf(false) }
|
||||
val inlineRequestsTransitionLockMs = 340L
|
||||
val inlineCallsTransitionLockMs = 340L
|
||||
val requestsRouteTapLockMs = 420L
|
||||
|
||||
fun setInlineRequestsVisible(visible: Boolean) {
|
||||
if (showRequestsScreen == visible || isInlineRequestsTransitionLocked) return
|
||||
if (visible) showCallsScreen = false
|
||||
isInlineRequestsTransitionLocked = true
|
||||
showRequestsScreen = visible
|
||||
scope.launch {
|
||||
@@ -536,6 +475,52 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun setInlineCallsVisible(visible: Boolean) {
|
||||
if (showCallsScreen == visible || isInlineCallsTransitionLocked) return
|
||||
if (visible) showRequestsScreen = false
|
||||
isInlineCallsTransitionLocked = true
|
||||
showCallsScreen = visible
|
||||
if (!visible) showCallsMenu = false
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(inlineCallsTransitionLockMs)
|
||||
isInlineCallsTransitionLocked = false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAllCallsHistory(): Int {
|
||||
if (accountPublicKey.isBlank()) return 0
|
||||
val messageDao = database.messageDao()
|
||||
val dialogDao = database.dialogDao()
|
||||
val peers = messageDao.getCallHistoryPeers(accountPublicKey).map { it.trim() }
|
||||
.filter { it.isNotBlank() }.distinct()
|
||||
|
||||
val deletedCount = messageDao.deleteAllCallMessages(accountPublicKey)
|
||||
if (deletedCount <= 0) return 0
|
||||
|
||||
peers.forEach { peerKey ->
|
||||
val dialogKey =
|
||||
if (accountPublicKey == peerKey) {
|
||||
accountPublicKey
|
||||
} else if (accountPublicKey < peerKey) {
|
||||
"$accountPublicKey:$peerKey"
|
||||
} else {
|
||||
"$peerKey:$accountPublicKey"
|
||||
}
|
||||
|
||||
val remaining = messageDao.getMessageCount(accountPublicKey, dialogKey)
|
||||
if (remaining > 0) {
|
||||
if (peerKey == accountPublicKey) {
|
||||
dialogDao.updateSavedMessagesDialogFromMessages(accountPublicKey)
|
||||
} else {
|
||||
dialogDao.updateDialogFromMessages(accountPublicKey, peerKey)
|
||||
}
|
||||
} else {
|
||||
dialogDao.deleteDialog(accountPublicKey, peerKey)
|
||||
}
|
||||
}
|
||||
return deletedCount
|
||||
}
|
||||
|
||||
fun openRequestsRouteSafely() {
|
||||
if (isRequestsRouteTapLocked) return
|
||||
isRequestsRouteTapLocked = true
|
||||
@@ -548,6 +533,7 @@ fun ChatsListScreen(
|
||||
|
||||
LaunchedEffect(currentAccountKey) {
|
||||
showDownloadsScreen = false
|
||||
showCallsScreen = false
|
||||
}
|
||||
|
||||
// 📂 Accounts section expanded state (arrow toggle)
|
||||
@@ -571,6 +557,7 @@ fun ChatsListScreen(
|
||||
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
var accountToDelete by remember { mutableStateOf<EncryptedAccount?>(null) }
|
||||
var showDeleteCallsDialog by remember { mutableStateOf(false) }
|
||||
var deviceResolveRequest by
|
||||
remember {
|
||||
mutableStateOf<Pair<DeviceEntry, DeviceResolveAction>?>(null)
|
||||
@@ -587,9 +574,11 @@ fun ChatsListScreen(
|
||||
|
||||
// Back: drawer → закрыть, selection → сбросить
|
||||
// Когда ничего не открыто — НЕ перехватываем, система сама закроет приложение корректно
|
||||
BackHandler(enabled = showDownloadsScreen || isSelectionMode || drawerState.isOpen) {
|
||||
BackHandler(enabled = showDownloadsScreen || showCallsScreen || isSelectionMode || drawerState.isOpen) {
|
||||
if (showDownloadsScreen) {
|
||||
showDownloadsScreen = false
|
||||
} else if (showCallsScreen) {
|
||||
setInlineCallsVisible(false)
|
||||
} else if (isSelectionMode) {
|
||||
selectedChatKeys = emptySet()
|
||||
} else if (drawerState.isOpen) {
|
||||
@@ -607,6 +596,7 @@ fun ChatsListScreen(
|
||||
|
||||
// Requests count for badge on hamburger & sidebar
|
||||
val topLevelChatsState by chatsViewModel.chatsState.collectAsState()
|
||||
val topLevelIsLoading by chatsViewModel.isLoading.collectAsState()
|
||||
val topLevelRequestsCount = topLevelChatsState.requestsCount
|
||||
|
||||
// Dev console dialog - commented out for now
|
||||
@@ -766,7 +756,7 @@ fun ChatsListScreen(
|
||||
) {
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = !showRequestsScreen && !showDownloadsScreen,
|
||||
gesturesEnabled = !showRequestsScreen && !showDownloadsScreen && !showCallsScreen,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(
|
||||
drawerContainerColor = Color.Transparent,
|
||||
@@ -1194,6 +1184,23 @@ fun ChatsListScreen(
|
||||
}
|
||||
)
|
||||
|
||||
// 📞 Calls
|
||||
DrawerMenuItemEnhanced(
|
||||
icon = TablerIcons.Phone,
|
||||
text = "Calls",
|
||||
iconColor = menuIconColor,
|
||||
textColor = menuTextColor,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
drawerState.close()
|
||||
kotlinx.coroutines
|
||||
.delay(100)
|
||||
setInlineCallsVisible(true)
|
||||
onCallsClick()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 👥 New Group
|
||||
DrawerMenuItemEnhanced(
|
||||
icon = TablerIcons.Users,
|
||||
@@ -1413,6 +1420,7 @@ fun ChatsListScreen(
|
||||
key(
|
||||
isDarkTheme,
|
||||
showRequestsScreen,
|
||||
showCallsScreen,
|
||||
showDownloadsScreen,
|
||||
isSelectionMode
|
||||
) {
|
||||
@@ -1553,11 +1561,15 @@ fun ChatsListScreen(
|
||||
// ═══ NORMAL HEADER ═══
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (showRequestsScreen || showDownloadsScreen) {
|
||||
if (showRequestsScreen || showDownloadsScreen || showCallsScreen) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (showDownloadsScreen) {
|
||||
showDownloadsScreen = false
|
||||
} else if (showCallsScreen) {
|
||||
setInlineCallsVisible(
|
||||
false
|
||||
)
|
||||
} else {
|
||||
setInlineRequestsVisible(
|
||||
false
|
||||
@@ -1650,6 +1662,13 @@ fun ChatsListScreen(
|
||||
fontSize = 20.sp,
|
||||
color = Color.White
|
||||
)
|
||||
} else if (showCallsScreen) {
|
||||
Text(
|
||||
"Calls",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
color = Color.White
|
||||
)
|
||||
} else if (showRequestsScreen) {
|
||||
Text(
|
||||
"Requests",
|
||||
@@ -1689,7 +1708,50 @@ fun ChatsListScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (!showRequestsScreen && !showDownloadsScreen) {
|
||||
if (showCallsScreen) {
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = {
|
||||
showCallsMenu = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
TablerIcons.DotsVertical,
|
||||
contentDescription = "Calls menu",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showCallsMenu,
|
||||
onDismissRequest = {
|
||||
showCallsMenu = false
|
||||
},
|
||||
modifier = Modifier.background(
|
||||
if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
|
||||
)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
"Delete all calls",
|
||||
color = if (isDarkTheme) Color.White else Color.Black
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showCallsMenu = false
|
||||
showDeleteCallsDialog = true
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = TablerIcons.Trash,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFE55A5A)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (!showRequestsScreen && !showDownloadsScreen) {
|
||||
// 📥 Animated download indicator (Telegram-style)
|
||||
Box(
|
||||
modifier =
|
||||
@@ -1803,8 +1865,8 @@ fun ChatsListScreen(
|
||||
// Это предотвращает "дергание" UI когда dialogs и requests
|
||||
// обновляются
|
||||
// независимо
|
||||
val chatsState by chatsViewModel.chatsState.collectAsState()
|
||||
val isLoading by chatsViewModel.isLoading.collectAsState()
|
||||
val chatsState = topLevelChatsState
|
||||
val isLoading = topLevelIsLoading
|
||||
val requests = chatsState.requests
|
||||
val requestsCount = chatsState.requestsCount
|
||||
|
||||
@@ -1898,6 +1960,48 @@ fun ChatsListScreen(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
// 🎬 Animated content transition between main list and
|
||||
// calls
|
||||
AnimatedContent(
|
||||
targetState = showCallsScreen,
|
||||
transitionSpec = {
|
||||
if (targetState) {
|
||||
slideInHorizontally(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing)
|
||||
) { fullWidth -> fullWidth } + fadeIn(
|
||||
animationSpec = tween(200)
|
||||
) togetherWith
|
||||
slideOutHorizontally(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing)
|
||||
) { fullWidth -> -fullWidth / 4 } + fadeOut(
|
||||
animationSpec = tween(150)
|
||||
)
|
||||
} else {
|
||||
slideInHorizontally(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing)
|
||||
) { fullWidth -> -fullWidth / 4 } + fadeIn(
|
||||
animationSpec = tween(200)
|
||||
) togetherWith
|
||||
slideOutHorizontally(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing)
|
||||
) { fullWidth -> fullWidth } + fadeOut(
|
||||
animationSpec = tween(150)
|
||||
)
|
||||
}
|
||||
},
|
||||
label = "CallsTransition"
|
||||
) { isCallsScreen ->
|
||||
if (isCallsScreen) {
|
||||
CallsRouteContent(
|
||||
isDarkTheme = isDarkTheme,
|
||||
accountPublicKey = accountPublicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
onUserSelect = onUserSelect,
|
||||
onStartCall = onStartCall,
|
||||
onStartNewCall = onSearchClick,
|
||||
onBack = { setInlineCallsVisible(false) }
|
||||
)
|
||||
} else {
|
||||
// 🎬 Animated content transition between main list and
|
||||
// requests
|
||||
AnimatedContent(
|
||||
@@ -1932,110 +2036,42 @@ fun ChatsListScreen(
|
||||
label = "RequestsTransition"
|
||||
) { isRequestsScreen ->
|
||||
if (isRequestsScreen) {
|
||||
// 📬 Show Requests Screen with swipe-back
|
||||
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(
|
||||
RequestsRouteContent(
|
||||
requests = requests,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onBack = {
|
||||
setInlineRequestsVisible(
|
||||
false
|
||||
)
|
||||
},
|
||||
onRequestClick = { request ->
|
||||
val user =
|
||||
chatsViewModel
|
||||
.dialogToSearchUser(
|
||||
request
|
||||
)
|
||||
onUserSelect(user)
|
||||
},
|
||||
avatarRepository =
|
||||
avatarRepository,
|
||||
avatarRepository = avatarRepository,
|
||||
blockedUsers = blockedUsers,
|
||||
pinnedChats = pinnedChats,
|
||||
isDrawerOpen =
|
||||
drawerState.isOpen ||
|
||||
drawerState
|
||||
.isAnimationRunning,
|
||||
onTogglePin = { opponentKey ->
|
||||
onTogglePin(opponentKey)
|
||||
},
|
||||
drawerState.isAnimationRunning,
|
||||
onTogglePin = onTogglePin,
|
||||
onDeleteDialog = { opponentKey ->
|
||||
scope.launch {
|
||||
chatsViewModel
|
||||
.deleteDialog(
|
||||
opponentKey
|
||||
)
|
||||
chatsViewModel.deleteDialog(opponentKey)
|
||||
}
|
||||
},
|
||||
onBlockUser = { opponentKey ->
|
||||
scope.launch {
|
||||
chatsViewModel
|
||||
.blockUser(
|
||||
opponentKey
|
||||
)
|
||||
chatsViewModel.blockUser(opponentKey)
|
||||
}
|
||||
},
|
||||
onUnblockUser = { opponentKey ->
|
||||
scope.launch {
|
||||
chatsViewModel
|
||||
.unblockUser(
|
||||
opponentKey
|
||||
)
|
||||
chatsViewModel.unblockUser(opponentKey)
|
||||
}
|
||||
},
|
||||
onRequestClick = { request ->
|
||||
val user =
|
||||
chatsViewModel.dialogToSearchUser(
|
||||
request
|
||||
)
|
||||
onUserSelect(user)
|
||||
},
|
||||
onBack = {
|
||||
setInlineRequestsVisible(false)
|
||||
}
|
||||
)
|
||||
} // Close Box wrapper
|
||||
} else if (showSkeleton) {
|
||||
ChatsListSkeleton(isDarkTheme = isDarkTheme)
|
||||
} else if (isLoading && chatsState.isEmpty) {
|
||||
@@ -2061,27 +2097,27 @@ fun ChatsListScreen(
|
||||
}
|
||||
// 🔥 Берем dialogs из chatsState для
|
||||
// консистентности
|
||||
// 📌 Сортируем: pinned сначала, потом по
|
||||
// времени
|
||||
// 📌 Порядок по времени готовится в ViewModel.
|
||||
// Здесь поднимаем pinned наверх без полного sort/distinct.
|
||||
val currentDialogs =
|
||||
remember(
|
||||
chatsState.dialogs,
|
||||
pinnedChats
|
||||
) {
|
||||
chatsState.dialogs
|
||||
.distinctBy { it.opponentKey }
|
||||
.sortedWith(
|
||||
compareByDescending<
|
||||
DialogUiModel> {
|
||||
pinnedChats
|
||||
.contains(
|
||||
it.opponentKey
|
||||
)
|
||||
}
|
||||
.thenByDescending {
|
||||
it.lastMessageTimestamp
|
||||
}
|
||||
)
|
||||
val pinned = ArrayList<DialogUiModel>()
|
||||
val regular = ArrayList<DialogUiModel>()
|
||||
chatsState.dialogs.forEach { dialog ->
|
||||
if (
|
||||
pinnedChats.contains(
|
||||
dialog.opponentKey
|
||||
)
|
||||
) {
|
||||
pinned.add(dialog)
|
||||
} else {
|
||||
regular.add(dialog)
|
||||
}
|
||||
}
|
||||
pinned + regular
|
||||
}
|
||||
|
||||
// Telegram-style: only one item can be
|
||||
@@ -2592,7 +2628,9 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
} // Close AnimatedContent
|
||||
} // Close Requests AnimatedContent
|
||||
} // Close calls/main switch
|
||||
} // Close Calls AnimatedContent
|
||||
} // Close downloads/main content switch
|
||||
} // Close Downloads AnimatedContent
|
||||
|
||||
@@ -2604,6 +2642,56 @@ fun ChatsListScreen(
|
||||
|
||||
// 🔥 Confirmation Dialogs
|
||||
|
||||
if (showDeleteCallsDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteCallsDialog = false },
|
||||
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
|
||||
title = {
|
||||
Text(
|
||||
"Delete all calls",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
"This will remove all call records from history. Chats and contacts will stay unchanged.",
|
||||
color = secondaryTextColor
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteCallsDialog = false
|
||||
scope.launch {
|
||||
val deleted = clearAllCallsHistory()
|
||||
if (deleted > 0) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Deleted $deleted call records",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Call history is already empty",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Delete", color = Color(0xFFE55A5A))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteCallsDialog = false }) {
|
||||
Text("Cancel", color = PrimaryBlue)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Delete Dialog Confirmation
|
||||
if (dialogsToDelete.isNotEmpty()) {
|
||||
val count = dialogsToDelete.size
|
||||
@@ -4645,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 */
|
||||
@Composable
|
||||
fun RequestsSection(
|
||||
|
||||
@@ -17,8 +17,6 @@ import com.rosetta.messenger.network.SearchUser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -80,6 +78,14 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
// 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл
|
||||
private val subscribedOnlineKeys = mutableSetOf<String>()
|
||||
|
||||
private data class DialogUiCacheEntry(
|
||||
val signature: Int,
|
||||
val model: DialogUiModel
|
||||
)
|
||||
|
||||
private val dialogsUiCache = LinkedHashMap<String, DialogUiCacheEntry>()
|
||||
private val requestsUiCache = LinkedHashMap<String, DialogUiCacheEntry>()
|
||||
|
||||
// Job для отмены подписок при смене аккаунта
|
||||
private var accountSubscriptionsJob: Job? = null
|
||||
|
||||
@@ -147,19 +153,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
if (!isGroupKey(dialog.opponentKey) || dialog.lastMessageTimestamp <= 0L) return null
|
||||
|
||||
val senderKey =
|
||||
if (dialog.lastMessageFromMe == 1) {
|
||||
currentAccount
|
||||
} else {
|
||||
val lastMessage =
|
||||
try {
|
||||
dialogDao.getLastMessageByDialogKey(
|
||||
account = dialog.account,
|
||||
dialogKey = dialog.opponentKey.trim()
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
lastMessage?.fromPublicKey.orEmpty()
|
||||
dialog.lastSenderKey.trim().ifBlank {
|
||||
if (dialog.lastMessageFromMe == 1) currentAccount else ""
|
||||
}
|
||||
|
||||
if (senderKey.isBlank()) return null
|
||||
@@ -226,6 +221,123 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
return null
|
||||
}
|
||||
|
||||
private fun buildDialogSignature(dialog: com.rosetta.messenger.database.DialogEntity): Int {
|
||||
var result = dialog.id.hashCode()
|
||||
result = 31 * result + dialog.account.hashCode()
|
||||
result = 31 * result + dialog.opponentKey.hashCode()
|
||||
result = 31 * result + dialog.opponentTitle.hashCode()
|
||||
result = 31 * result + dialog.opponentUsername.hashCode()
|
||||
result = 31 * result + dialog.lastMessage.hashCode()
|
||||
result = 31 * result + dialog.lastMessageTimestamp.hashCode()
|
||||
result = 31 * result + dialog.unreadCount
|
||||
result = 31 * result + dialog.isOnline
|
||||
result = 31 * result + dialog.lastSeen.hashCode()
|
||||
result = 31 * result + dialog.verified
|
||||
result = 31 * result + dialog.lastMessageFromMe
|
||||
result = 31 * result + dialog.lastMessageDelivered
|
||||
result = 31 * result + dialog.lastMessageRead
|
||||
result = 31 * result + dialog.lastMessageAttachments.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
private fun shouldRequestDialogUserInfo(
|
||||
dialog: com.rosetta.messenger.database.DialogEntity,
|
||||
isRequestsFlow: Boolean
|
||||
): Boolean {
|
||||
val title = dialog.opponentTitle
|
||||
if (isRequestsFlow) {
|
||||
return title.isEmpty() || title == dialog.opponentKey
|
||||
}
|
||||
return title.isEmpty() ||
|
||||
title == dialog.opponentKey ||
|
||||
title == dialog.opponentKey.take(7) ||
|
||||
title == dialog.opponentKey.take(8)
|
||||
}
|
||||
|
||||
private fun normalizeDialogList(dialogs: List<DialogUiModel>): List<DialogUiModel> {
|
||||
if (dialogs.isEmpty()) return emptyList()
|
||||
val deduped = LinkedHashMap<String, DialogUiModel>(dialogs.size)
|
||||
dialogs.forEach { dialog ->
|
||||
if (!deduped.containsKey(dialog.opponentKey)) {
|
||||
deduped[dialog.opponentKey] = dialog
|
||||
}
|
||||
}
|
||||
return deduped.values.sortedByDescending { it.lastMessageTimestamp }
|
||||
}
|
||||
|
||||
private suspend fun mapDialogListIncremental(
|
||||
dialogsList: List<com.rosetta.messenger.database.DialogEntity>,
|
||||
privateKey: String,
|
||||
cache: LinkedHashMap<String, DialogUiCacheEntry>,
|
||||
isRequestsFlow: Boolean
|
||||
): List<DialogUiModel> {
|
||||
return withContext(Dispatchers.Default) {
|
||||
val activeKeys = HashSet<String>(dialogsList.size)
|
||||
val mapped = ArrayList<DialogUiModel>(dialogsList.size)
|
||||
|
||||
dialogsList.forEach { dialog ->
|
||||
val cacheKey = dialog.opponentKey
|
||||
activeKeys.add(cacheKey)
|
||||
|
||||
val signature = buildDialogSignature(dialog)
|
||||
val cached = cache[cacheKey]
|
||||
if (cached != null && cached.signature == signature) {
|
||||
mapped.add(cached.model)
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val isSavedMessages = dialog.account == dialog.opponentKey
|
||||
if (!isSavedMessages && shouldRequestDialogUserInfo(dialog, isRequestsFlow)) {
|
||||
if (isRequestsFlow) {
|
||||
loadUserInfoForRequest(dialog.opponentKey)
|
||||
} else {
|
||||
loadUserInfoForDialog(dialog.opponentKey)
|
||||
}
|
||||
}
|
||||
|
||||
val decryptedLastMessage =
|
||||
decryptDialogPreview(
|
||||
encryptedLastMessage = dialog.lastMessage,
|
||||
privateKey = privateKey
|
||||
)
|
||||
val attachmentType =
|
||||
resolveAttachmentType(
|
||||
attachmentType = dialog.lastMessageAttachmentType,
|
||||
decryptedLastMessage = decryptedLastMessage
|
||||
)
|
||||
val groupLastSenderInfo = resolveGroupLastSenderInfo(dialog)
|
||||
|
||||
val model =
|
||||
DialogUiModel(
|
||||
id = dialog.id,
|
||||
account = dialog.account,
|
||||
opponentKey = dialog.opponentKey,
|
||||
opponentTitle = dialog.opponentTitle,
|
||||
opponentUsername = dialog.opponentUsername,
|
||||
lastMessage = decryptedLastMessage,
|
||||
lastMessageTimestamp = dialog.lastMessageTimestamp,
|
||||
unreadCount = dialog.unreadCount,
|
||||
isOnline = dialog.isOnline,
|
||||
lastSeen = dialog.lastSeen,
|
||||
verified = dialog.verified,
|
||||
isSavedMessages = isSavedMessages,
|
||||
lastMessageFromMe = dialog.lastMessageFromMe,
|
||||
lastMessageDelivered = dialog.lastMessageDelivered,
|
||||
lastMessageRead = dialog.lastMessageRead,
|
||||
lastMessageAttachmentType = attachmentType,
|
||||
lastMessageSenderPrefix = groupLastSenderInfo?.senderPrefix,
|
||||
lastMessageSenderKey = groupLastSenderInfo?.senderKey
|
||||
)
|
||||
|
||||
cache[cacheKey] = DialogUiCacheEntry(signature = signature, model = model)
|
||||
mapped.add(model)
|
||||
}
|
||||
|
||||
cache.keys.retainAll(activeKeys)
|
||||
normalizeDialogList(mapped)
|
||||
}
|
||||
}
|
||||
|
||||
/** Установить текущий аккаунт и загрузить диалоги */
|
||||
fun setAccount(publicKey: String, privateKey: String) {
|
||||
val setAccountStart = System.currentTimeMillis()
|
||||
@@ -241,6 +353,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
||||
requestedUserInfoKeys.clear()
|
||||
subscribedOnlineKeys.clear()
|
||||
dialogsUiCache.clear()
|
||||
requestsUiCache.clear()
|
||||
|
||||
// 🔥 Очищаем глобальный кэш сообщений ChatViewModel при смене аккаунта
|
||||
// чтобы избежать показа сообщений с неправильным isOutgoing
|
||||
@@ -280,104 +394,16 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
|
||||
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
|
||||
.map { dialogsList ->
|
||||
val mapStart = System.currentTimeMillis()
|
||||
// <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений
|
||||
withContext(Dispatchers.Default) {
|
||||
dialogsList
|
||||
.map { dialog ->
|
||||
async {
|
||||
// 🔥 Загружаем информацию о пользователе если её нет
|
||||
// 📁 НЕ загружаем для Saved Messages
|
||||
val isSavedMessages =
|
||||
(dialog.account == dialog.opponentKey)
|
||||
if (!isSavedMessages &&
|
||||
(dialog.opponentTitle.isEmpty() ||
|
||||
dialog.opponentTitle ==
|
||||
dialog.opponentKey ||
|
||||
dialog.opponentTitle ==
|
||||
dialog.opponentKey.take(
|
||||
7
|
||||
) ||
|
||||
dialog.opponentTitle ==
|
||||
dialog.opponentKey.take(
|
||||
8
|
||||
))
|
||||
) {
|
||||
loadUserInfoForDialog(dialog.opponentKey)
|
||||
}
|
||||
|
||||
// Безопасная дешифровка превью: никогда не показываем raw ciphertext.
|
||||
val decryptedLastMessage =
|
||||
decryptDialogPreview(
|
||||
encryptedLastMessage =
|
||||
dialog.lastMessage,
|
||||
privateKey = privateKey
|
||||
)
|
||||
|
||||
// <20> ОПТИМИЗАЦИЯ: Используем денормализованные поля из
|
||||
// DialogEntity
|
||||
// Статус и attachments уже записаны в dialogs через
|
||||
// updateDialogFromMessages()
|
||||
// Это устраняет N+1 проблему (ранее: 2 запроса на
|
||||
// каждый диалог)
|
||||
|
||||
// 📎 Определяем тип attachment из кэшированного поля в
|
||||
// DialogEntity
|
||||
val attachmentType =
|
||||
resolveAttachmentType(
|
||||
attachmentsJson =
|
||||
dialog.lastMessageAttachments,
|
||||
decryptedLastMessage =
|
||||
decryptedLastMessage
|
||||
)
|
||||
val groupLastSenderInfo =
|
||||
resolveGroupLastSenderInfo(dialog)
|
||||
|
||||
DialogUiModel(
|
||||
id = dialog.id,
|
||||
account = dialog.account,
|
||||
opponentKey = dialog.opponentKey,
|
||||
opponentTitle = dialog.opponentTitle,
|
||||
opponentUsername = dialog.opponentUsername,
|
||||
lastMessage = decryptedLastMessage,
|
||||
lastMessageTimestamp =
|
||||
dialog.lastMessageTimestamp,
|
||||
unreadCount = dialog.unreadCount,
|
||||
isOnline = dialog.isOnline,
|
||||
lastSeen = dialog.lastSeen,
|
||||
verified = dialog.verified,
|
||||
isSavedMessages =
|
||||
isSavedMessages, // 📁 Saved Messages
|
||||
lastMessageFromMe =
|
||||
dialog.lastMessageFromMe, // 🚀 Из
|
||||
// DialogEntity (денормализовано)
|
||||
lastMessageDelivered =
|
||||
dialog.lastMessageDelivered, // 🚀 Из
|
||||
// DialogEntity (денормализовано)
|
||||
lastMessageRead =
|
||||
dialog.lastMessageRead, // 🚀 Из
|
||||
// DialogEntity
|
||||
// (денормализовано)
|
||||
lastMessageAttachmentType =
|
||||
attachmentType, // 📎 Тип attachment
|
||||
lastMessageSenderPrefix =
|
||||
groupLastSenderInfo?.senderPrefix,
|
||||
lastMessageSenderKey =
|
||||
groupLastSenderInfo?.senderKey
|
||||
)
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
.also {
|
||||
val mapTime = System.currentTimeMillis() - mapStart
|
||||
}
|
||||
mapDialogListIncremental(
|
||||
dialogsList = dialogsList,
|
||||
privateKey = privateKey,
|
||||
cache = dialogsUiCache,
|
||||
isRequestsFlow = false
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||
.collect { decryptedDialogs ->
|
||||
// Deduplicate by opponentKey to prevent LazyColumn crash
|
||||
// (Key "X" was already used)
|
||||
_dialogs.value = decryptedDialogs.distinctBy { it.opponentKey }
|
||||
_dialogs.value = decryptedDialogs
|
||||
// 🚀 Убираем skeleton после первой загрузки
|
||||
if (_isLoading.value) _isLoading.value = false
|
||||
|
||||
@@ -400,83 +426,15 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
.flowOn(Dispatchers.IO)
|
||||
.debounce(100) // 🚀 Батчим быстрые обновления
|
||||
.map { requestsList ->
|
||||
// 🚀 ОПТИМИЗАЦИЯ: Параллельная расшифровка
|
||||
withContext(Dispatchers.Default) {
|
||||
requestsList
|
||||
.map { dialog ->
|
||||
async {
|
||||
// 🔥 Загружаем информацию о пользователе если её нет
|
||||
// 📁 НЕ загружаем для Saved Messages
|
||||
val isSavedMessages =
|
||||
(dialog.account == dialog.opponentKey)
|
||||
if (!isSavedMessages &&
|
||||
(dialog.opponentTitle.isEmpty() ||
|
||||
dialog.opponentTitle ==
|
||||
dialog.opponentKey)
|
||||
) {
|
||||
loadUserInfoForRequest(dialog.opponentKey)
|
||||
}
|
||||
|
||||
// Безопасная дешифровка превью: никогда не показываем raw ciphertext.
|
||||
val decryptedLastMessage =
|
||||
decryptDialogPreview(
|
||||
encryptedLastMessage =
|
||||
dialog.lastMessage,
|
||||
privateKey = privateKey
|
||||
)
|
||||
|
||||
// 📎 Определяем тип attachment из кэшированного поля в
|
||||
// DialogEntity
|
||||
val attachmentType =
|
||||
resolveAttachmentType(
|
||||
attachmentsJson =
|
||||
dialog.lastMessageAttachments,
|
||||
decryptedLastMessage =
|
||||
decryptedLastMessage
|
||||
)
|
||||
val groupLastSenderInfo =
|
||||
resolveGroupLastSenderInfo(dialog)
|
||||
|
||||
DialogUiModel(
|
||||
id = dialog.id,
|
||||
account = dialog.account,
|
||||
opponentKey = dialog.opponentKey,
|
||||
opponentTitle =
|
||||
dialog.opponentTitle, // 🔥 Показываем
|
||||
// имя как в
|
||||
// обычных чатах
|
||||
opponentUsername = dialog.opponentUsername,
|
||||
lastMessage = decryptedLastMessage,
|
||||
lastMessageTimestamp =
|
||||
dialog.lastMessageTimestamp,
|
||||
unreadCount = dialog.unreadCount,
|
||||
isOnline = dialog.isOnline,
|
||||
lastSeen = dialog.lastSeen,
|
||||
verified = dialog.verified,
|
||||
isSavedMessages =
|
||||
(dialog.account ==
|
||||
dialog.opponentKey), // 📁 Saved
|
||||
// Messages
|
||||
lastMessageFromMe = dialog.lastMessageFromMe,
|
||||
lastMessageDelivered =
|
||||
dialog.lastMessageDelivered,
|
||||
lastMessageRead = dialog.lastMessageRead,
|
||||
lastMessageAttachmentType =
|
||||
attachmentType, // 📎 Тип attachment
|
||||
lastMessageSenderPrefix =
|
||||
groupLastSenderInfo?.senderPrefix,
|
||||
lastMessageSenderKey =
|
||||
groupLastSenderInfo?.senderKey
|
||||
)
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
mapDialogListIncremental(
|
||||
dialogsList = requestsList,
|
||||
privateKey = privateKey,
|
||||
cache = requestsUiCache,
|
||||
isRequestsFlow = true
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||
.collect { decryptedRequests ->
|
||||
_requests.value = decryptedRequests.distinctBy { it.opponentKey }
|
||||
}
|
||||
.collect { decryptedRequests -> _requests.value = decryptedRequests }
|
||||
}
|
||||
|
||||
// 📊 Подписываемся на количество requests
|
||||
@@ -584,38 +542,20 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
}
|
||||
|
||||
private fun resolveAttachmentType(
|
||||
attachmentsJson: String,
|
||||
attachmentType: Int,
|
||||
decryptedLastMessage: String
|
||||
): String? {
|
||||
if (attachmentsJson.isEmpty() || attachmentsJson == "[]") return null
|
||||
|
||||
return try {
|
||||
val attachments = org.json.JSONArray(attachmentsJson)
|
||||
if (attachments.length() == 0) return null
|
||||
|
||||
val firstAttachment = attachments.getJSONObject(0)
|
||||
when (firstAttachment.optInt("type", -1)) {
|
||||
0 -> "Photo" // AttachmentType.IMAGE = 0
|
||||
1 -> {
|
||||
// AttachmentType.MESSAGES = 1 (Reply/Forward).
|
||||
// Если текст пустой — показываем "Forwarded" как в desktop.
|
||||
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
|
||||
}
|
||||
2 -> "File" // AttachmentType.FILE = 2
|
||||
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
||||
4 -> "Call" // AttachmentType.CALL = 4
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Fallback: если JSON поврежден, но видно MESSAGES attachment и текста нет — это forward.
|
||||
val hasMessagesType =
|
||||
attachmentsJson.contains("\"type\":1") ||
|
||||
attachmentsJson.contains("\"type\": 1")
|
||||
if (decryptedLastMessage.isEmpty() && hasMessagesType) {
|
||||
"Forwarded"
|
||||
} else {
|
||||
null
|
||||
return when (attachmentType) {
|
||||
0 -> "Photo" // AttachmentType.IMAGE = 0
|
||||
1 -> {
|
||||
// AttachmentType.MESSAGES = 1 (Reply/Forward).
|
||||
// Если текст пустой — показываем "Forwarded" как в desktop.
|
||||
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
|
||||
}
|
||||
2 -> "File" // AttachmentType.FILE = 2
|
||||
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
||||
4 -> "Call" // AttachmentType.CALL = 4
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,6 +641,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
_requests.value = _requests.value.filter { it.opponentKey != opponentKey }
|
||||
// 🔥 Обновляем счетчик requests
|
||||
_requestsCount.value = _requests.value.size
|
||||
dialogsUiCache.remove(opponentKey)
|
||||
requestsUiCache.remove(opponentKey)
|
||||
|
||||
// Вычисляем правильный dialog_key
|
||||
val dialogKey =
|
||||
@@ -760,6 +702,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
_dialogs.value = _dialogs.value.filter { it.opponentKey != groupPublicKey }
|
||||
_requests.value = _requests.value.filter { it.opponentKey != groupPublicKey }
|
||||
_requestsCount.value = _requests.value.size
|
||||
dialogsUiCache.remove(groupPublicKey)
|
||||
requestsUiCache.remove(groupPublicKey)
|
||||
MessageRepository.getInstance(getApplication()).clearDialogCache(groupPublicKey)
|
||||
ChatViewModel.clearCacheForOpponent(groupPublicKey)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.database.MessageEntity
|
||||
import com.rosetta.messenger.database.MessageSearchIndexEntity
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
@@ -63,9 +63,6 @@ import com.rosetta.messenger.network.ProtocolState
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
@@ -1005,9 +1002,10 @@ private fun MessagesTabContent(
|
||||
}
|
||||
|
||||
// Persistent decryption cache: messageId → plaintext (survives re-queries)
|
||||
val decryptCache = remember { ConcurrentHashMap<String, String>(512) }
|
||||
val decryptCache = remember(currentUserPublicKey) { ConcurrentHashMap<String, String>(512) }
|
||||
// Cache for dialog metadata: opponentKey → (title, username, verified)
|
||||
val dialogCache = remember { ConcurrentHashMap<String, Triple<String, String, Int>>() }
|
||||
val dialogCache =
|
||||
remember(currentUserPublicKey) { ConcurrentHashMap<String, Triple<String, String, Int>>() }
|
||||
|
||||
val dateFormat = remember { SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()) }
|
||||
|
||||
@@ -1044,67 +1042,66 @@ private fun MessagesTabContent(
|
||||
}
|
||||
|
||||
val queryLower = searchQuery.trim().lowercase()
|
||||
val matched = mutableListOf<MessageSearchResult>()
|
||||
val semaphore = Semaphore(4)
|
||||
val batchSize = 200
|
||||
var offset = 0
|
||||
val maxMessages = 5000 // Safety cap
|
||||
val maxResults = 50 // Don't return more than 50 matches
|
||||
val batchSize = 200
|
||||
val indexDao = db.messageSearchIndexDao()
|
||||
|
||||
while (offset < maxMessages && matched.size < maxResults) {
|
||||
val batch = db.messageDao().getAllMessagesPaged(
|
||||
currentUserPublicKey, batchSize, offset
|
||||
fun toUiResult(item: MessageSearchIndexEntity): MessageSearchResult {
|
||||
val normalized = item.opponentKey.trim()
|
||||
val meta = dialogCache[normalized]
|
||||
return MessageSearchResult(
|
||||
messageId = item.messageId,
|
||||
dialogKey = item.dialogKey,
|
||||
opponentKey = normalized,
|
||||
opponentTitle = meta?.first.orEmpty(),
|
||||
opponentUsername = meta?.second.orEmpty(),
|
||||
plainText = item.plainText,
|
||||
timestamp = item.timestamp,
|
||||
fromMe = item.fromMe == 1,
|
||||
verified = meta?.third ?: 0
|
||||
)
|
||||
if (batch.isEmpty()) break
|
||||
|
||||
// Decrypt in parallel, filter by query
|
||||
val batchResults = kotlinx.coroutines.coroutineScope {
|
||||
batch.chunked(20).flatMap { chunk ->
|
||||
chunk.map { msg ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
val cached = decryptCache[msg.messageId]
|
||||
val plain = if (cached != null) {
|
||||
cached
|
||||
} else {
|
||||
val decrypted = try {
|
||||
CryptoManager.decryptWithPassword(
|
||||
msg.plainMessage, privateKey
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
if (!decrypted.isNullOrBlank()) {
|
||||
decryptCache[msg.messageId] = decrypted
|
||||
}
|
||||
decrypted
|
||||
}
|
||||
|
||||
if (!plain.isNullOrBlank() && plain.lowercase().contains(queryLower)) {
|
||||
val opponent = if (msg.fromMe == 1) msg.toPublicKey else msg.fromPublicKey
|
||||
val normalized = opponent.trim()
|
||||
val meta = dialogCache[normalized]
|
||||
MessageSearchResult(
|
||||
messageId = msg.messageId,
|
||||
dialogKey = msg.dialogKey,
|
||||
opponentKey = normalized,
|
||||
opponentTitle = meta?.first.orEmpty(),
|
||||
opponentUsername = meta?.second.orEmpty(),
|
||||
plainText = plain,
|
||||
timestamp = msg.timestamp,
|
||||
fromMe = msg.fromMe == 1,
|
||||
verified = meta?.third ?: 0
|
||||
)
|
||||
} else null
|
||||
}
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
matched.addAll(batchResults)
|
||||
offset += batchSize
|
||||
}
|
||||
|
||||
results = matched.take(maxResults)
|
||||
var indexed = indexDao.search(currentUserPublicKey, queryLower, maxResults, 0)
|
||||
var indexingPasses = 0
|
||||
while (indexed.size < maxResults && indexingPasses < 15) {
|
||||
val unindexed = indexDao.getUnindexedMessages(currentUserPublicKey, batchSize)
|
||||
if (unindexed.isEmpty()) break
|
||||
|
||||
val rows = ArrayList<MessageSearchIndexEntity>(unindexed.size)
|
||||
unindexed.forEach { msg ->
|
||||
val plain =
|
||||
decryptCache[msg.messageId]
|
||||
?: try {
|
||||
CryptoManager.decryptWithPassword(msg.plainMessage, privateKey)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}.orEmpty()
|
||||
if (plain.isNotEmpty()) {
|
||||
decryptCache[msg.messageId] = plain
|
||||
}
|
||||
val opponent = if (msg.fromMe == 1) msg.toPublicKey else msg.fromPublicKey
|
||||
rows.add(
|
||||
MessageSearchIndexEntity(
|
||||
account = currentUserPublicKey,
|
||||
messageId = msg.messageId,
|
||||
dialogKey = msg.dialogKey,
|
||||
opponentKey = opponent.trim(),
|
||||
timestamp = msg.timestamp,
|
||||
fromMe = msg.fromMe,
|
||||
plainText = plain,
|
||||
plainTextNormalized = plain.lowercase()
|
||||
)
|
||||
)
|
||||
}
|
||||
if (rows.isNotEmpty()) {
|
||||
indexDao.upsert(rows)
|
||||
}
|
||||
indexingPasses++
|
||||
indexed = indexDao.search(currentUserPublicKey, queryLower, maxResults, 0)
|
||||
}
|
||||
|
||||
results = indexed.map(::toUiResult)
|
||||
} catch (_: Exception) {
|
||||
results = emptyList()
|
||||
}
|
||||
|
||||
@@ -96,27 +96,17 @@ fun CallOverlay(
|
||||
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
|
||||
)
|
||||
) {
|
||||
// ── Top bar: "Encrypted" left + QR icon right ──
|
||||
if (state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) {
|
||||
Row(
|
||||
// ── Top-right QR icon ──
|
||||
if ((state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) && state.keyCast.isNotBlank()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.TopCenter)
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Text(
|
||||
text = "\uD83D\uDD12 Encrypted",
|
||||
color = Color.White.copy(alpha = 0.4f),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
|
||||
// QR grid icon — tap to show popover
|
||||
if (state.keyCast.isNotBlank()) {
|
||||
EncryptionKeyButton(keyHex = state.keyCast)
|
||||
}
|
||||
EncryptionKeyButton(keyHex = state.keyCast)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
package com.rosetta.messenger.ui.chats.calls
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.CallMade
|
||||
import androidx.compose.material.icons.filled.CallReceived
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.rosetta.messenger.database.CallHistoryRow
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.Phone
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import org.json.JSONArray
|
||||
|
||||
private data class CallHistoryItem(
|
||||
val messageId: String,
|
||||
val peerKey: String,
|
||||
val peerTitle: String,
|
||||
val peerUsername: String,
|
||||
val peerVerified: Int,
|
||||
val peerOnline: Int,
|
||||
val timestamp: Long,
|
||||
val isOutgoing: Boolean,
|
||||
val durationSec: Int,
|
||||
val isMissed: Boolean
|
||||
) {
|
||||
fun toSearchUser(): SearchUser =
|
||||
SearchUser(
|
||||
publicKey = peerKey,
|
||||
title = peerTitle,
|
||||
username = peerUsername,
|
||||
verified = peerVerified,
|
||||
online = peerOnline
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CallsHistoryScreen(
|
||||
isDarkTheme: Boolean,
|
||||
accountPublicKey: String,
|
||||
avatarRepository: AvatarRepository?,
|
||||
onOpenChat: (SearchUser) -> Unit,
|
||||
onStartCall: (SearchUser) -> Unit,
|
||||
onStartNewCall: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val messageDao = remember(context) { RosettaDatabase.getDatabase(context).messageDao() }
|
||||
|
||||
val rows by produceState(initialValue = emptyList<CallHistoryRow>(), accountPublicKey) {
|
||||
if (accountPublicKey.isBlank()) {
|
||||
value = emptyList()
|
||||
return@produceState
|
||||
}
|
||||
messageDao.getCallHistoryFlow(accountPublicKey).collect { value = it }
|
||||
}
|
||||
|
||||
val items = remember(rows) { rows.map { it.toCallHistoryItem() } }
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||
val textColor = if (isDarkTheme) Color.White else Color(0xFF111111)
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF7A7A80)
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF2D2D2F) else Color(0xFFE7E7EA)
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize().background(backgroundColor),
|
||||
contentPadding = PaddingValues(bottom = 16.dp)
|
||||
) {
|
||||
item(key = "start_new_call") {
|
||||
StartNewCallRow(
|
||||
isDarkTheme = isDarkTheme,
|
||||
onClick = onStartNewCall
|
||||
)
|
||||
Divider(color = dividerColor, thickness = 0.5.dp)
|
||||
Text(
|
||||
text = "You can add up to 200 participants to a call.",
|
||||
color = secondaryTextColor,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
)
|
||||
Divider(color = dividerColor, thickness = 0.5.dp)
|
||||
}
|
||||
|
||||
if (items.isEmpty()) {
|
||||
item(key = "empty_calls") {
|
||||
EmptyCallsState(
|
||||
isDarkTheme = isDarkTheme,
|
||||
title = "No calls yet",
|
||||
subtitle = "Your call history will appear here",
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 64.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(items, key = { it.messageId }) { item ->
|
||||
CallHistoryRowItem(
|
||||
item = item,
|
||||
isDarkTheme = isDarkTheme,
|
||||
avatarRepository = avatarRepository,
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor,
|
||||
onOpenChat = onOpenChat,
|
||||
onStartCall = onStartCall
|
||||
)
|
||||
Divider(color = dividerColor, thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StartNewCallRow(
|
||||
isDarkTheme: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val rowColor = if (isDarkTheme) Color(0xFF1B2B3A) else Color(0xFFEAF4FF)
|
||||
val textColor = if (isDarkTheme) Color(0xFF74B8FF) else Color(0xFF1A73E8)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(rowColor).clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.Phone,
|
||||
contentDescription = null,
|
||||
tint = textColor,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Start New Call",
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallHistoryRowItem(
|
||||
item: CallHistoryItem,
|
||||
isDarkTheme: Boolean,
|
||||
avatarRepository: AvatarRepository?,
|
||||
textColor: Color,
|
||||
secondaryTextColor: Color,
|
||||
onOpenChat: (SearchUser) -> Unit,
|
||||
onStartCall: (SearchUser) -> Unit
|
||||
) {
|
||||
val subtitleColor =
|
||||
when {
|
||||
item.isMissed -> Color(0xFFE55A5A)
|
||||
isDarkTheme -> Color(0xFF56D97A)
|
||||
else -> Color(0xFF1EA75E)
|
||||
}
|
||||
val directionIconColor =
|
||||
if (item.durationSec == 0) Color(0xFFE55A5A) else subtitleColor
|
||||
val directionIcon =
|
||||
when {
|
||||
item.durationSec == 0 -> Icons.Default.Close
|
||||
item.isOutgoing -> Icons.Default.CallMade
|
||||
else -> Icons.Default.CallReceived
|
||||
}
|
||||
val subtitleText =
|
||||
when {
|
||||
item.durationSec > 0 -> "${item.directionLabel()} ${formatCallTimestamp(item.timestamp)}"
|
||||
else -> item.directionLabel() + " " + formatCallTimestamp(item.timestamp)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable { onOpenChat(item.toSearchUser()) }
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AvatarImage(
|
||||
publicKey = item.peerKey,
|
||||
avatarRepository = avatarRepository,
|
||||
size = 52.dp,
|
||||
isDarkTheme = isDarkTheme,
|
||||
displayName = item.peerTitle
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = item.peerTitle,
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(3.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = directionIcon,
|
||||
contentDescription = null,
|
||||
tint = directionIconColor,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = subtitleText,
|
||||
color = if (item.isMissed) Color(0xFFE55A5A) else secondaryTextColor,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { onStartCall(item.toSearchUser()) }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = "Call",
|
||||
tint = PrimaryBlue
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyCallsState(
|
||||
isDarkTheme: Boolean,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val iconTint = if (isDarkTheme) Color(0xFF5B5C63) else Color(0xFFAFB0B8)
|
||||
val titleColor = if (isDarkTheme) Color(0xFFE1E1E6) else Color(0xFF1F1F23)
|
||||
val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF7A7A80)
|
||||
|
||||
Column(
|
||||
modifier = modifier.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(72.dp).background(iconTint.copy(alpha = 0.2f), CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(34.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Text(
|
||||
text = title,
|
||||
color = titleColor,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = subtitleColor,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CallHistoryRow.toCallHistoryItem(): CallHistoryItem {
|
||||
val displayName = resolveDisplayName(peerTitle.orEmpty(), peerUsername.orEmpty(), peerKey)
|
||||
val username = peerUsername.orEmpty().trim().trimStart('@')
|
||||
val durationSec = parseCallDurationFromAttachments(message.attachments)
|
||||
val isOutgoing = message.fromMe == 1
|
||||
val isMissed = !isOutgoing && durationSec == 0
|
||||
|
||||
return CallHistoryItem(
|
||||
messageId = message.messageId,
|
||||
peerKey = peerKey,
|
||||
peerTitle = displayName,
|
||||
peerUsername = username,
|
||||
peerVerified = peerVerified ?: 0,
|
||||
peerOnline = peerOnline ?: 0,
|
||||
timestamp = message.timestamp,
|
||||
isOutgoing = isOutgoing,
|
||||
durationSec = durationSec,
|
||||
isMissed = isMissed
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveDisplayName(title: String, username: String, publicKey: String): String {
|
||||
val normalizedTitle = title.trim()
|
||||
if (normalizedTitle.isNotEmpty() &&
|
||||
normalizedTitle != publicKey &&
|
||||
normalizedTitle != publicKey.take(7) &&
|
||||
normalizedTitle != publicKey.take(8)
|
||||
) {
|
||||
return normalizedTitle
|
||||
}
|
||||
|
||||
val normalizedUsername = username.trim().trimStart('@')
|
||||
if (normalizedUsername.isNotEmpty()) return normalizedUsername
|
||||
|
||||
return publicKey.take(8)
|
||||
}
|
||||
|
||||
private fun parseCallDurationFromAttachments(attachmentsJson: String): Int {
|
||||
if (attachmentsJson.isBlank() || attachmentsJson == "[]") return 0
|
||||
return runCatching {
|
||||
val attachments = JSONArray(attachmentsJson)
|
||||
for (i in 0 until attachments.length()) {
|
||||
val attachment = attachments.optJSONObject(i) ?: continue
|
||||
if (attachment.optInt("type", -1) != 4) continue
|
||||
return parseCallDurationSeconds(attachment.optString("preview", ""))
|
||||
}
|
||||
0
|
||||
}.getOrDefault(0)
|
||||
}
|
||||
|
||||
private fun parseCallDurationSeconds(preview: String): Int {
|
||||
if (preview.isBlank()) return 0
|
||||
|
||||
preview.substringAfterLast("::").trim().toIntOrNull()?.let {
|
||||
return it.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
val durationRegex =
|
||||
Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*(\\d+)", RegexOption.IGNORE_CASE)
|
||||
durationRegex.find(preview)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let {
|
||||
return it.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
return preview.trim().toIntOrNull()?.coerceAtLeast(0) ?: 0
|
||||
}
|
||||
|
||||
private fun CallHistoryItem.directionLabel(): String {
|
||||
return when {
|
||||
durationSec == 0 && isOutgoing -> "Rejected call"
|
||||
durationSec == 0 && !isOutgoing -> "Missed call"
|
||||
isOutgoing -> "Outgoing call"
|
||||
else -> "Incoming call"
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCallTimestamp(timestamp: Long): String {
|
||||
if (timestamp <= 0L) return ""
|
||||
val now = Calendar.getInstance()
|
||||
val callTime = Calendar.getInstance().apply { timeInMillis = timestamp }
|
||||
|
||||
val sameYear = now.get(Calendar.YEAR) == callTime.get(Calendar.YEAR)
|
||||
val sameDay =
|
||||
sameYear && now.get(Calendar.DAY_OF_YEAR) == callTime.get(Calendar.DAY_OF_YEAR)
|
||||
|
||||
val yesterday = now.clone() as Calendar
|
||||
yesterday.add(Calendar.DAY_OF_YEAR, -1)
|
||||
val isYesterday =
|
||||
sameYear && yesterday.get(Calendar.DAY_OF_YEAR) == callTime.get(Calendar.DAY_OF_YEAR)
|
||||
|
||||
return when {
|
||||
sameDay -> SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
|
||||
isYesterday -> "Yesterday"
|
||||
else -> SimpleDateFormat("dd MMM", Locale.getDefault()).format(Date(timestamp))
|
||||
}
|
||||
}
|
||||
@@ -1593,7 +1593,7 @@ private fun resolveDesktopCallUi(preview: String, isOutgoing: Boolean): DesktopC
|
||||
}
|
||||
val subtitle =
|
||||
if (isError) {
|
||||
"Call was not answered or was rejected"
|
||||
if (isOutgoing) "Rejected" else "Missed"
|
||||
} else {
|
||||
formatDesktopCallDuration(durationSec)
|
||||
}
|
||||
@@ -1612,19 +1612,14 @@ fun CallAttachment(
|
||||
val callUi = remember(attachment.preview, isOutgoing) {
|
||||
resolveDesktopCallUi(attachment.preview, isOutgoing)
|
||||
}
|
||||
val containerShape = RoundedCornerShape(10.dp)
|
||||
val containerShape = RoundedCornerShape(17.dp)
|
||||
val containerBackground =
|
||||
if (isOutgoing) {
|
||||
Color.White.copy(alpha = 0.12f)
|
||||
PrimaryBlue
|
||||
} else {
|
||||
if (isDarkTheme) Color(0xFF1F2733) else Color(0xFFF3F8FF)
|
||||
}
|
||||
val containerBorder =
|
||||
if (isOutgoing) {
|
||||
Color.White.copy(alpha = 0.2f)
|
||||
} else {
|
||||
if (isDarkTheme) Color(0xFF33435A) else Color(0xFFD8E5F4)
|
||||
if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
|
||||
}
|
||||
val containerBorder = Color.Transparent
|
||||
val iconBackground = if (callUi.isError) Color(0xFFE55A5A) else PrimaryBlue
|
||||
val iconVector =
|
||||
when {
|
||||
@@ -1690,59 +1685,6 @@ fun CallAttachment(
|
||||
)
|
||||
}
|
||||
|
||||
if (isOutgoing) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
|
||||
fontSize = 11.sp,
|
||||
color = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
when (messageStatus) {
|
||||
MessageStatus.SENDING -> {
|
||||
Icon(
|
||||
painter = TelegramIcons.Clock,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
MessageStatus.SENT, MessageStatus.DELIVERED -> {
|
||||
Icon(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.8f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
MessageStatus.READ -> {
|
||||
Box(modifier = Modifier.height(14.dp)) {
|
||||
Icon(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Icon(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(14.dp).offset(x = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
MessageStatus.ERROR -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFE53935),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,6 +666,15 @@ fun MessageBubble(
|
||||
.IMAGE
|
||||
}
|
||||
|
||||
val isCallMessage =
|
||||
message.attachments.isNotEmpty() &&
|
||||
message.text.isEmpty() &&
|
||||
message.replyData == null &&
|
||||
message.forwardedMessages.isEmpty() &&
|
||||
message.attachments.all {
|
||||
it.type == AttachmentType.CALL
|
||||
}
|
||||
|
||||
val isStandaloneGroupInvite =
|
||||
message.attachments.isEmpty() &&
|
||||
message.replyData == null &&
|
||||
@@ -794,7 +803,8 @@ fun MessageBubble(
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
.then(
|
||||
if (false) {
|
||||
if (isCallMessage) {
|
||||
// Звонки без фонового пузырька — у них свой контейнер внутри CallAttachment
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.clip(bubbleShape)
|
||||
|
||||
@@ -652,12 +652,17 @@ fun MessageInputBar(
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
if (panelReplyMessages.isNotEmpty()) {
|
||||
val msg = panelReplyMessages.first()
|
||||
val hasImageAttachment = msg.attachments.any {
|
||||
val hasImageAttachment = msg.attachments.any {
|
||||
it.type == AttachmentType.IMAGE
|
||||
}
|
||||
val hasCallAttachment = msg.attachments.any {
|
||||
it.type == AttachmentType.CALL
|
||||
}
|
||||
AppleEmojiText(
|
||||
text = if (panelReplyMessages.size == 1) {
|
||||
if (msg.text.isEmpty() && hasImageAttachment) {
|
||||
if (msg.text.isEmpty() && hasCallAttachment) {
|
||||
"Call"
|
||||
} else if (msg.text.isEmpty() && hasImageAttachment) {
|
||||
"Photo"
|
||||
} else {
|
||||
val shortText = msg.text.take(40)
|
||||
|
||||
@@ -29,5 +29,5 @@ dependencies {
|
||||
implementation("androidx.test.ext:junit:1.1.5")
|
||||
implementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
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(":baselineprofile")
|
||||
include(":benchmark")
|
||||
|
||||
Reference in New Issue
Block a user