Compare commits

..

9 Commits

27 changed files with 2012 additions and 878 deletions

View File

@@ -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
### Групповые чаты и медиа

View File

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

View File

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

View File

@@ -456,6 +456,7 @@ class GroupRepository private constructor(context: Context) {
messageId = UUID.randomUUID().toString().replace("-", "").take(32),
plainMessage = encryptedPlainMessage,
attachments = "[]",
primaryAttachmentType = -1,
dialogKey = dialogPublicKey
)
)

View File

@@ -8,6 +8,7 @@ import com.rosetta.messenger.network.*
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.MessageLogger
import java.util.Locale
import java.util.UUID
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
@@ -51,6 +52,7 @@ class MessageRepository private constructor(private val context: Context) {
private val avatarDao = database.avatarDao()
private val syncTimeDao = database.syncTimeDao()
private val groupDao = database.groupDao()
private val searchIndexDao = database.messageSearchIndexDao()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -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

View File

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

View File

@@ -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,14 +597,69 @@ 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(
"""
@@ -572,6 +673,47 @@ interface MessageDao {
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,

View File

@@ -12,13 +12,14 @@ import androidx.sqlite.db.SupportSQLiteDatabase
[
EncryptedAccountEntity::class,
MessageEntity::class,
MessageSearchIndexEntity::class,
DialogEntity::class,
BlacklistEntity::class,
AvatarCacheEntity::class,
AccountSyncTimeEntity::class,
GroupEntity::class,
PinnedMessageEntity::class],
version = 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() // Для разработки - только
// если миграция не

View File

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

View File

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

View File

@@ -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
}
this.stream = stream.copyOf()
this.readPointer = 0
this.writePointer = this.stream.size shl 3
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 getBuffer(): ByteArray = getStream()
fun writeInt8(value: Int) {
val negationBit = if (value < 0) 1 else 0
val int8Value = Math.abs(value) and 0xFF
fun isEmpty(): Boolean = writePointer == 0
ensureCapacity(_writePointer shr 3)
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (negationBit shl (7 - (_writePointer and 7)))
_writePointer++
fun length(): Int = (writePointer + 7) shr 3
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++
}
}
fun getReadPointerBits(): Int = readPointer
fun readInt8(): Int {
var value = 0
val negationBit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
_readPointer++
fun getTotalBits(): Int = writePointer
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++
}
fun getRemainingBits(): Int = writePointer - readPointer
fun hasRemainingBits(): Boolean = readPointer < writePointer
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 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 readBoolean(): Boolean {
return readBit() == 1
}
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
}
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
val high = readInt8() shl 8
return high or readInt8()
}
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 readUInt64(): ULong {
val high = readUInt32().toULong()
val low = readUInt32().toULong()
return (high shl 32) or low
fun readInt32(): Int {
val high = readInt16() shl 16
return high or readInt16()
}
fun writeInt64(value: Long) {
writeUInt64(value.toULong())
val high = (value shr 32).toInt()
val low = (value and 0xFFFFFFFF).toInt()
writeInt32(high)
writeInt32(low)
}
fun readInt64(): Long = readUInt64().toLong()
fun writeFloat32(value: Float) {
val bits = value.toRawBits().toLong() and 0xFFFF_FFFFL
writeUInt32(bits)
fun readInt64(): Long {
val high = readInt32().toLong()
val low = (readInt32().toLong() and 0xFFFFFFFFL)
return (high shl 32) or low
}
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 sb = StringBuilder()
for (i in 0 until length) {
sb.append(readInt16().toChar())
}
return sb.toString()
}
val requiredBits = len * 16L
if (requiredBits > remainingBits()) {
throw IllegalStateException("Not enough bits to read string")
}
val chars = CharArray(len.toInt())
for (i in chars.indices) {
chars[i] = readUInt16().toChar()
}
return String(chars)
}
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
}
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())
return bytes
}
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
}
val next = ByteArray(newSize)
System.arraycopy(stream, 0, next, 0, stream.size)
stream = next
while (_stream.size <= index) {
_stream.add(0)
}
}
}

View File

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

View File

@@ -98,6 +98,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val database = RosettaDatabase.getDatabase(application)
private val dialogDao = database.dialogDao()
private val messageDao = database.messageDao()
private val searchIndexDao = database.messageSearchIndexDao()
private val groupDao = database.groupDao()
private val pinnedMessageDao = database.pinnedMessageDao()
@@ -126,6 +127,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// UI State
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
@Volatile private var normalizedMessagesDescCache: List<ChatMessage> = emptyList()
/**
* Pre-computed messages with date headers — runs dedup + sort on Dispatchers.Default. Replaces
@@ -143,20 +145,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
.debounce(16) // coalesce rapid updates (1 frame)
.mapLatest { rawMessages ->
withContext(Dispatchers.Default) {
val unique = rawMessages.distinctBy { it.id }
val sorted = unique.sortedWith(chatMessageDescComparator)
val result = ArrayList<Pair<ChatMessage, Boolean>>(sorted.size)
var prevDateStr: String? = null
for (i in sorted.indices) {
val msg = sorted[i]
val dateStr = _dateFmt.format(msg.timestamp)
val nextMsg = sorted.getOrNull(i + 1)
val nextDateStr = nextMsg?.let { _dateFmt.format(it.timestamp) }
val showDate = nextDateStr == null || nextDateStr != dateStr
result.add(msg to showDate)
prevDateStr = dateStr
}
result as List<Pair<ChatMessage, Boolean>>
val normalized =
normalizeMessagesDescendingIncremental(
previous = normalizedMessagesDescCache,
incoming = rawMessages
)
normalizedMessagesDescCache = normalized
buildMessagesWithDateHeaders(normalized)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
@@ -234,6 +229,98 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private fun sortMessagesAscending(messages: List<ChatMessage>): List<ChatMessage> =
messages.sortedWith(chatMessageAscComparator)
private fun isSortedDescending(messages: List<ChatMessage>): Boolean {
if (messages.size < 2) return true
for (i in 0 until messages.lastIndex) {
if (chatMessageDescComparator.compare(messages[i], messages[i + 1]) > 0) {
return false
}
}
return true
}
private fun insertIntoSortedDescending(
existing: List<ChatMessage>,
message: ChatMessage
): List<ChatMessage> {
if (existing.isEmpty()) return listOf(message)
val result = ArrayList<ChatMessage>(existing.size + 1)
var inserted = false
existing.forEach { current ->
if (!inserted && chatMessageDescComparator.compare(message, current) <= 0) {
result.add(message)
inserted = true
}
result.add(current)
}
if (!inserted) result.add(message)
return result
}
private fun normalizeMessagesDescendingIncremental(
previous: List<ChatMessage>,
incoming: List<ChatMessage>
): List<ChatMessage> {
if (incoming.isEmpty()) return emptyList()
val dedupedById = LinkedHashMap<String, ChatMessage>(incoming.size)
incoming.forEach { message -> dedupedById[message.id] = message }
if (previous.isNotEmpty() && dedupedById.size == previous.size) {
var unchanged = true
previous.forEach { message ->
if (dedupedById[message.id] != message) {
unchanged = false
return@forEach
}
}
if (unchanged && isSortedDescending(previous)) {
return previous
}
}
if (previous.isNotEmpty() && dedupedById.size == previous.size + 1) {
val previousIds = HashSet<String>(previous.size)
previous.forEach { previousIds.add(it.id) }
val addedIds = dedupedById.keys.filter { it !in previousIds }
if (addedIds.size == 1) {
var previousUnchanged = true
previous.forEach { message ->
if (dedupedById[message.id] != message) {
previousUnchanged = false
return@forEach
}
}
if (previousUnchanged) {
val addedMessage = dedupedById.getValue(addedIds.first())
return insertIntoSortedDescending(previous, addedMessage)
}
}
}
val normalized = ArrayList<ChatMessage>(dedupedById.values)
if (!isSortedDescending(normalized)) {
normalized.sortWith(chatMessageDescComparator)
}
return normalized
}
private fun buildMessagesWithDateHeaders(
sortedMessagesDesc: List<ChatMessage>
): List<Pair<ChatMessage, Boolean>> {
val result = ArrayList<Pair<ChatMessage, Boolean>>(sortedMessagesDesc.size)
for (i in sortedMessagesDesc.indices) {
val msg = sortedMessagesDesc[i]
val dateStr = _dateFmt.format(msg.timestamp)
val nextMsg = sortedMessagesDesc.getOrNull(i + 1)
val nextDateStr = nextMsg?.let { _dateFmt.format(it.timestamp) }
val showDate = nextDateStr == null || nextDateStr != dateStr
result.add(msg to showDate)
}
return result
}
private fun latestIncomingMessage(messages: List<ChatMessage>): ChatMessage? =
messages.asSequence().filter { !it.isOutgoing }.maxWithOrNull(chatMessageAscComparator)
@@ -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

View File

@@ -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
@@ -1897,6 +1959,48 @@ fun ChatsListScreen(
isDarkTheme = isDarkTheme,
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
@@ -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
val pinned = ArrayList<DialogUiModel>()
val regular = ArrayList<DialogUiModel>()
chatsState.dialogs.forEach { dialog ->
if (
pinnedChats.contains(
dialog.opponentKey
)
) {
pinned.add(dialog)
} else {
regular.add(dialog)
}
.thenByDescending {
it.lastMessageTimestamp
}
)
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(

View File

@@ -17,8 +17,6 @@ import com.rosetta.messenger.network.SearchUser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -80,6 +78,14 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл
private val subscribedOnlineKeys = mutableSetOf<String>()
private data class DialogUiCacheEntry(
val signature: Int,
val model: DialogUiModel
)
private val dialogsUiCache = LinkedHashMap<String, DialogUiCacheEntry>()
private val requestsUiCache = LinkedHashMap<String, DialogUiCacheEntry>()
// Job для отмены подписок при смене аккаунта
private var accountSubscriptionsJob: Job? = null
@@ -147,19 +153,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
if (!isGroupKey(dialog.opponentKey) || dialog.lastMessageTimestamp <= 0L) return null
val senderKey =
if (dialog.lastMessageFromMe == 1) {
currentAccount
} else {
val lastMessage =
try {
dialogDao.getLastMessageByDialogKey(
account = dialog.account,
dialogKey = dialog.opponentKey.trim()
)
} catch (_: Exception) {
null
}
lastMessage?.fromPublicKey.orEmpty()
dialog.lastSenderKey.trim().ifBlank {
if (dialog.lastMessageFromMe == 1) currentAccount else ""
}
if (senderKey.isBlank()) return null
@@ -226,6 +221,123 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
return null
}
private fun buildDialogSignature(dialog: com.rosetta.messenger.database.DialogEntity): Int {
var result = dialog.id.hashCode()
result = 31 * result + dialog.account.hashCode()
result = 31 * result + dialog.opponentKey.hashCode()
result = 31 * result + dialog.opponentTitle.hashCode()
result = 31 * result + dialog.opponentUsername.hashCode()
result = 31 * result + dialog.lastMessage.hashCode()
result = 31 * result + dialog.lastMessageTimestamp.hashCode()
result = 31 * result + dialog.unreadCount
result = 31 * result + dialog.isOnline
result = 31 * result + dialog.lastSeen.hashCode()
result = 31 * result + dialog.verified
result = 31 * result + dialog.lastMessageFromMe
result = 31 * result + dialog.lastMessageDelivered
result = 31 * result + dialog.lastMessageRead
result = 31 * result + dialog.lastMessageAttachments.hashCode()
return result
}
private fun shouldRequestDialogUserInfo(
dialog: com.rosetta.messenger.database.DialogEntity,
isRequestsFlow: Boolean
): Boolean {
val title = dialog.opponentTitle
if (isRequestsFlow) {
return title.isEmpty() || title == dialog.opponentKey
}
return title.isEmpty() ||
title == dialog.opponentKey ||
title == dialog.opponentKey.take(7) ||
title == dialog.opponentKey.take(8)
}
private fun normalizeDialogList(dialogs: List<DialogUiModel>): List<DialogUiModel> {
if (dialogs.isEmpty()) return emptyList()
val deduped = LinkedHashMap<String, DialogUiModel>(dialogs.size)
dialogs.forEach { dialog ->
if (!deduped.containsKey(dialog.opponentKey)) {
deduped[dialog.opponentKey] = dialog
}
}
return deduped.values.sortedByDescending { it.lastMessageTimestamp }
}
private suspend fun mapDialogListIncremental(
dialogsList: List<com.rosetta.messenger.database.DialogEntity>,
privateKey: String,
cache: LinkedHashMap<String, DialogUiCacheEntry>,
isRequestsFlow: Boolean
): List<DialogUiModel> {
return withContext(Dispatchers.Default) {
val activeKeys = HashSet<String>(dialogsList.size)
val mapped = ArrayList<DialogUiModel>(dialogsList.size)
dialogsList.forEach { dialog ->
val cacheKey = dialog.opponentKey
activeKeys.add(cacheKey)
val signature = buildDialogSignature(dialog)
val cached = cache[cacheKey]
if (cached != null && cached.signature == signature) {
mapped.add(cached.model)
return@forEach
}
val isSavedMessages = dialog.account == dialog.opponentKey
if (!isSavedMessages && shouldRequestDialogUserInfo(dialog, isRequestsFlow)) {
if (isRequestsFlow) {
loadUserInfoForRequest(dialog.opponentKey)
} else {
loadUserInfoForDialog(dialog.opponentKey)
}
}
val decryptedLastMessage =
decryptDialogPreview(
encryptedLastMessage = dialog.lastMessage,
privateKey = privateKey
)
val attachmentType =
resolveAttachmentType(
attachmentType = dialog.lastMessageAttachmentType,
decryptedLastMessage = decryptedLastMessage
)
val groupLastSenderInfo = resolveGroupLastSenderInfo(dialog)
val model =
DialogUiModel(
id = dialog.id,
account = dialog.account,
opponentKey = dialog.opponentKey,
opponentTitle = dialog.opponentTitle,
opponentUsername = dialog.opponentUsername,
lastMessage = decryptedLastMessage,
lastMessageTimestamp = dialog.lastMessageTimestamp,
unreadCount = dialog.unreadCount,
isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen,
verified = dialog.verified,
isSavedMessages = isSavedMessages,
lastMessageFromMe = dialog.lastMessageFromMe,
lastMessageDelivered = dialog.lastMessageDelivered,
lastMessageRead = dialog.lastMessageRead,
lastMessageAttachmentType = attachmentType,
lastMessageSenderPrefix = groupLastSenderInfo?.senderPrefix,
lastMessageSenderKey = groupLastSenderInfo?.senderKey
)
cache[cacheKey] = DialogUiCacheEntry(signature = signature, model = model)
mapped.add(model)
}
cache.keys.retainAll(activeKeys)
normalizeDialogList(mapped)
}
}
/** Установить текущий аккаунт и загрузить диалоги */
fun setAccount(publicKey: String, privateKey: String) {
val setAccountStart = System.currentTimeMillis()
@@ -241,6 +353,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
requestedUserInfoKeys.clear()
subscribedOnlineKeys.clear()
dialogsUiCache.clear()
requestsUiCache.clear()
// 🔥 Очищаем глобальный кэш сообщений ChatViewModel при смене аккаунта
// чтобы избежать показа сообщений с неправильным isOutgoing
@@ -280,104 +394,16 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
.map { dialogsList ->
val mapStart = System.currentTimeMillis()
// <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений
withContext(Dispatchers.Default) {
dialogsList
.map { dialog ->
async {
// 🔥 Загружаем информацию о пользователе если её нет
// 📁 НЕ загружаем для Saved Messages
val isSavedMessages =
(dialog.account == dialog.opponentKey)
if (!isSavedMessages &&
(dialog.opponentTitle.isEmpty() ||
dialog.opponentTitle ==
dialog.opponentKey ||
dialog.opponentTitle ==
dialog.opponentKey.take(
7
) ||
dialog.opponentTitle ==
dialog.opponentKey.take(
8
))
) {
loadUserInfoForDialog(dialog.opponentKey)
}
// Безопасная дешифровка превью: никогда не показываем raw ciphertext.
val decryptedLastMessage =
decryptDialogPreview(
encryptedLastMessage =
dialog.lastMessage,
privateKey = privateKey
mapDialogListIncremental(
dialogsList = dialogsList,
privateKey = privateKey,
cache = dialogsUiCache,
isRequestsFlow = false
)
// <20> ОПТИМИЗАЦИЯ: Используем денормализованные поля из
// DialogEntity
// Статус и attachments уже записаны в dialogs через
// updateDialogFromMessages()
// Это устраняет N+1 проблему (ранее: 2 запроса на
// каждый диалог)
// 📎 Определяем тип attachment из кэшированного поля в
// DialogEntity
val attachmentType =
resolveAttachmentType(
attachmentsJson =
dialog.lastMessageAttachments,
decryptedLastMessage =
decryptedLastMessage
)
val groupLastSenderInfo =
resolveGroupLastSenderInfo(dialog)
DialogUiModel(
id = dialog.id,
account = dialog.account,
opponentKey = dialog.opponentKey,
opponentTitle = dialog.opponentTitle,
opponentUsername = dialog.opponentUsername,
lastMessage = decryptedLastMessage,
lastMessageTimestamp =
dialog.lastMessageTimestamp,
unreadCount = dialog.unreadCount,
isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen,
verified = dialog.verified,
isSavedMessages =
isSavedMessages, // 📁 Saved Messages
lastMessageFromMe =
dialog.lastMessageFromMe, // 🚀 Из
// DialogEntity (денормализовано)
lastMessageDelivered =
dialog.lastMessageDelivered, // 🚀 Из
// DialogEntity (денормализовано)
lastMessageRead =
dialog.lastMessageRead, // 🚀 Из
// DialogEntity
// (денормализовано)
lastMessageAttachmentType =
attachmentType, // 📎 Тип attachment
lastMessageSenderPrefix =
groupLastSenderInfo?.senderPrefix,
lastMessageSenderKey =
groupLastSenderInfo?.senderKey
)
}
}
.awaitAll()
}
.also {
val mapTime = System.currentTimeMillis() - mapStart
}
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.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
mapDialogListIncremental(
dialogsList = requestsList,
privateKey = privateKey,
cache = requestsUiCache,
isRequestsFlow = true
)
// 📎 Определяем тип attachment из кэшированного поля в
// DialogEntity
val attachmentType =
resolveAttachmentType(
attachmentsJson =
dialog.lastMessageAttachments,
decryptedLastMessage =
decryptedLastMessage
)
val groupLastSenderInfo =
resolveGroupLastSenderInfo(dialog)
DialogUiModel(
id = dialog.id,
account = dialog.account,
opponentKey = dialog.opponentKey,
opponentTitle =
dialog.opponentTitle, // 🔥 Показываем
// имя как в
// обычных чатах
opponentUsername = dialog.opponentUsername,
lastMessage = decryptedLastMessage,
lastMessageTimestamp =
dialog.lastMessageTimestamp,
unreadCount = dialog.unreadCount,
isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen,
verified = dialog.verified,
isSavedMessages =
(dialog.account ==
dialog.opponentKey), // 📁 Saved
// Messages
lastMessageFromMe = dialog.lastMessageFromMe,
lastMessageDelivered =
dialog.lastMessageDelivered,
lastMessageRead = dialog.lastMessageRead,
lastMessageAttachmentType =
attachmentType, // 📎 Тип attachment
lastMessageSenderPrefix =
groupLastSenderInfo?.senderPrefix,
lastMessageSenderKey =
groupLastSenderInfo?.senderKey
)
}
}
.awaitAll()
}
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedRequests ->
_requests.value = decryptedRequests.distinctBy { it.opponentKey }
}
.collect { decryptedRequests -> _requests.value = decryptedRequests }
}
// 📊 Подписываемся на количество requests
@@ -584,17 +542,10 @@ 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)) {
return when (attachmentType) {
0 -> "Photo" // AttachmentType.IMAGE = 0
1 -> {
// AttachmentType.MESSAGES = 1 (Reply/Forward).
@@ -606,17 +557,6 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
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
}
}
}
private fun isLikelyEncryptedPayload(value: String): Boolean {
@@ -701,6 +641,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
_requests.value = _requests.value.filter { it.opponentKey != opponentKey }
// 🔥 Обновляем счетчик requests
_requestsCount.value = _requests.value.size
dialogsUiCache.remove(opponentKey)
requestsUiCache.remove(opponentKey)
// Вычисляем правильный dialog_key
val dialogKey =
@@ -760,6 +702,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
_dialogs.value = _dialogs.value.filter { it.opponentKey != groupPublicKey }
_requests.value = _requests.value.filter { it.opponentKey != groupPublicKey }
_requestsCount.value = _requests.value.size
dialogsUiCache.remove(groupPublicKey)
requestsUiCache.remove(groupPublicKey)
MessageRepository.getInstance(getApplication()).clearDialogCache(groupPublicKey)
ChatViewModel.clearCacheForOpponent(groupPublicKey)
}

View File

@@ -45,7 +45,7 @@ import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.database.MessageEntity
import com.rosetta.messenger.database.MessageSearchIndexEntity
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment
import androidx.compose.ui.graphics.asImageBitmap
@@ -63,9 +63,6 @@ import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import org.json.JSONArray
@@ -1005,9 +1002,10 @@ private fun MessagesTabContent(
}
// Persistent decryption cache: messageId → plaintext (survives re-queries)
val decryptCache = remember { ConcurrentHashMap<String, String>(512) }
val decryptCache = remember(currentUserPublicKey) { ConcurrentHashMap<String, String>(512) }
// Cache for dialog metadata: opponentKey → (title, username, verified)
val dialogCache = remember { ConcurrentHashMap<String, Triple<String, String, Int>>() }
val dialogCache =
remember(currentUserPublicKey) { ConcurrentHashMap<String, Triple<String, String, Int>>() }
val dateFormat = remember { SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()) }
@@ -1044,67 +1042,66 @@ private fun MessagesTabContent(
}
val queryLower = searchQuery.trim().lowercase()
val matched = mutableListOf<MessageSearchResult>()
val semaphore = Semaphore(4)
val batchSize = 200
var offset = 0
val maxMessages = 5000 // Safety cap
val maxResults = 50 // Don't return more than 50 matches
val batchSize = 200
val indexDao = db.messageSearchIndexDao()
while (offset < maxMessages && matched.size < maxResults) {
val batch = db.messageDao().getAllMessagesPaged(
currentUserPublicKey, batchSize, offset
)
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()
fun toUiResult(item: MessageSearchIndexEntity): MessageSearchResult {
val normalized = item.opponentKey.trim()
val meta = dialogCache[normalized]
MessageSearchResult(
messageId = msg.messageId,
dialogKey = msg.dialogKey,
return MessageSearchResult(
messageId = item.messageId,
dialogKey = item.dialogKey,
opponentKey = normalized,
opponentTitle = meta?.first.orEmpty(),
opponentUsername = meta?.second.orEmpty(),
plainText = plain,
timestamp = msg.timestamp,
fromMe = msg.fromMe == 1,
plainText = item.plainText,
timestamp = item.timestamp,
fromMe = item.fromMe == 1,
verified = meta?.third ?: 0
)
} else null
}
}
}.awaitAll().filterNotNull()
}
}
matched.addAll(batchResults)
offset += batchSize
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 = matched.take(maxResults)
results = indexed.map(::toUiResult)
} catch (_: Exception) {
results = emptyList()
}

View File

@@ -96,29 +96,19 @@ 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)
}
}
}
// ── Center content: rings + avatar + name + status ──
Column(

View File

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

View File

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

View File

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

View File

@@ -655,9 +655,14 @@ fun MessageInputBar(
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)

View File

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

View 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")
}

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />

View File

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

View File

@@ -19,3 +19,4 @@ rootProject.name = "rosetta-android"
include(":app")
include(":baselineprofile")
include(":benchmark")