Оптимизация приложения

This commit is contained in:
2026-03-27 19:19:15 +05:00
parent c3e97eee56
commit e7efe0856c
10 changed files with 279 additions and 171 deletions

View File

@@ -1,5 +1,14 @@
# Release Notes # Release Notes
## 1.3.3
### E2EE, чаты и производительность
- В release-сборке отключена frame-диагностика E2EE (детальный frame dump теперь только в debug).
- В `ChatsListScreen` убран двойной `collectAsState(chatsState)` и вынесены route-блоки в подкомпоненты (`CallsRouteContent`, `RequestsRouteContent`, общий `SwipeBackContainer`).
- Добавлена денормализация `primary_attachment_type` в таблице `messages` + индекс `(account, primary_attachment_type, timestamp)`.
- Обновлена миграция БД `14 -> 15`: добавление колонки, индекс и backfill значения типа вложения для уже сохраненных сообщений.
- Поисковые и call-history запросы переведены на `primary_attachment_type` с fallback на legacy `attachments LIKE` для старых записей.
## 1.2.3 ## 1.2.3
### Групповые чаты и медиа ### Групповые чаты и медиа

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.3.2" val rosettaVersionName = "1.3.3"
val rosettaVersionCode = 34 // Increment on each release val rosettaVersionCode = 35 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar") val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android { android {

View File

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

View File

@@ -207,6 +207,7 @@ class MessageRepository private constructor(private val context: Context) {
messageId = messageId, messageId = messageId,
plainMessage = encryptedPlainMessage, plainMessage = encryptedPlainMessage,
attachments = "[]", attachments = "[]",
primaryAttachmentType = -1,
dialogKey = dialogKey dialogKey = dialogKey
) )
) )
@@ -266,6 +267,7 @@ class MessageRepository private constructor(private val context: Context) {
messageId = messageId, messageId = messageId,
plainMessage = encryptedPlainMessage, plainMessage = encryptedPlainMessage,
attachments = "[]", attachments = "[]",
primaryAttachmentType = -1,
dialogKey = dialogKey dialogKey = dialogKey
) )
) )
@@ -528,6 +530,8 @@ class MessageRepository private constructor(private val context: Context) {
messageId = messageId, messageId = messageId,
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
attachments = attachmentsJson, attachments = attachmentsJson,
primaryAttachmentType =
resolvePrimaryAttachmentType(attachments),
replyToMessageId = replyToMessageId, replyToMessageId = replyToMessageId,
dialogKey = dialogKey dialogKey = dialogKey
) )
@@ -860,6 +864,8 @@ class MessageRepository private constructor(private val context: Context) {
messageId = messageId, // 🔥 Используем сгенерированный messageId! messageId = messageId, // 🔥 Используем сгенерированный messageId!
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
attachments = attachmentsJson, attachments = attachmentsJson,
primaryAttachmentType =
resolvePrimaryAttachmentType(packet.attachments),
dialogKey = dialogKey dialogKey = dialogKey
) )
@@ -1638,6 +1644,11 @@ class MessageRepository private constructor(private val context: Context) {
return jsonArray.toString() return jsonArray.toString()
} }
private fun resolvePrimaryAttachmentType(attachments: List<MessageAttachment>): Int {
if (attachments.isEmpty()) return -1
return attachments.first().type.value
}
/** /**
* 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш Как в desktop: при * 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш Как в desktop: при
* получении attachment с типом AVATAR - сохраняем в avatar_cache * получении attachment с типом AVATAR - сохраняем в avatar_cache

View File

@@ -17,10 +17,11 @@ object ReleaseNotes {
val RELEASE_NOTICE = """ val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
Защищенные звонки и диагностика E2EE Оптимизация E2EE и списка чатов
- Обновлен custom WebRTC для Android и исправлена совместимость аудио E2EE с Desktop - В release отключена frame-диагностика E2EE (детальные frame-логи только в debug)
- Улучшены diagnostics для шифрования звонков (детализация ENC/DEC в crash reports) - Упрощен ChatsListScreen: убрано дублирование collectAsState и вынесены route-компоненты
- В Crash Reports добавлена кнопка копирования полного лога одним действием - Ускорены выборки по вложениям: добавлен denormalized attachment type + индекс в БД
- Добавлена миграция БД с backfill типа вложения для старых сообщений
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -81,7 +81,8 @@ data class LastMessageStatus(
[ [
Index(value = ["account", "from_public_key", "to_public_key", "timestamp"]), Index(value = ["account", "from_public_key", "to_public_key", "timestamp"]),
Index(value = ["account", "message_id"], unique = true), Index(value = ["account", "message_id"], unique = true),
Index(value = ["account", "dialog_key", "timestamp"])] Index(value = ["account", "dialog_key", "timestamp"]),
Index(value = ["account", "primary_attachment_type", "timestamp"])]
) )
data class MessageEntity( data class MessageEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0, @PrimaryKey(autoGenerate = true) val id: Long = 0,
@@ -99,6 +100,8 @@ data class MessageEntity(
@ColumnInfo(name = "plain_message") @ColumnInfo(name = "plain_message")
val plainMessage: String, // 🔒 Зашифрованный текст (encryptWithPassword) для хранения в БД val plainMessage: String, // 🔒 Зашифрованный текст (encryptWithPassword) для хранения в БД
@ColumnInfo(name = "attachments") val attachments: String = "[]", // JSON массив вложений @ColumnInfo(name = "attachments") val attachments: String = "[]", // JSON массив вложений
@ColumnInfo(name = "primary_attachment_type", defaultValue = "-1")
val primaryAttachmentType: Int = -1, // Денормализованный тип 1-го вложения (-1 если нет)
@ColumnInfo(name = "reply_to_message_id") @ColumnInfo(name = "reply_to_message_id")
val replyToMessageId: String? = null, // ID цитируемого сообщения val replyToMessageId: String? = null, // ID цитируемого сообщения
@ColumnInfo(name = "dialog_key") val dialogKey: String // Ключ диалога для быстрой выборки @ColumnInfo(name = "dialog_key") val dialogKey: String // Ключ диалога для быстрой выборки
@@ -545,8 +548,10 @@ interface MessageDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account WHERE account = :account
AND attachments != '[]' AND (
AND attachments LIKE '%"type":0%' primary_attachment_type = 0
OR (primary_attachment_type = -1 AND attachments != '[]' AND attachments LIKE '%"type":0%')
)
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
""" """
@@ -561,8 +566,10 @@ interface MessageDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account WHERE account = :account
AND attachments != '[]' AND (
AND attachments LIKE '%"type":2%' primary_attachment_type = 2
OR (primary_attachment_type = -1 AND attachments != '[]' AND attachments LIKE '%"type":2%')
)
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
""" """
@@ -593,8 +600,14 @@ interface MessageDao {
ELSE m.from_public_key ELSE m.from_public_key
END END
WHERE m.account = :account WHERE m.account = :account
AND m.attachments != '[]' AND (
AND (m.attachments LIKE '%"type":4%' OR m.attachments LIKE '%"type": 4%') m.primary_attachment_type = 4
OR (
m.primary_attachment_type = -1
AND m.attachments != '[]'
AND (m.attachments LIKE '%"type":4%' OR m.attachments LIKE '%"type": 4%')
)
)
ORDER BY m.timestamp DESC, m.message_id DESC ORDER BY m.timestamp DESC, m.message_id DESC
LIMIT :limit LIMIT :limit
""" """
@@ -611,8 +624,14 @@ interface MessageDao {
END AS peer_key END AS peer_key
FROM messages FROM messages
WHERE account = :account WHERE account = :account
AND attachments != '[]' AND (
AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%') primary_attachment_type = 4
OR (
primary_attachment_type = -1
AND attachments != '[]'
AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%')
)
)
""" """
) )
suspend fun getCallHistoryPeers(account: String): List<String> suspend fun getCallHistoryPeers(account: String): List<String>
@@ -622,8 +641,14 @@ interface MessageDao {
""" """
DELETE FROM messages DELETE FROM messages
WHERE account = :account WHERE account = :account
AND attachments != '[]' AND (
AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%') primary_attachment_type = 4
OR (
primary_attachment_type = -1
AND attachments != '[]'
AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%')
)
)
""" """
) )
suspend fun deleteAllCallMessages(account: String): Int suspend fun deleteAllCallMessages(account: String): Int

View File

@@ -18,7 +18,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
AccountSyncTimeEntity::class, AccountSyncTimeEntity::class,
GroupEntity::class, GroupEntity::class,
PinnedMessageEntity::class], PinnedMessageEntity::class],
version = 14, version = 15,
exportSchema = false exportSchema = false
) )
abstract class RosettaDatabase : RoomDatabase() { abstract class RosettaDatabase : RoomDatabase() {
@@ -202,6 +202,36 @@ 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
"""
)
}
}
fun getDatabase(context: Context): RosettaDatabase { fun getDatabase(context: Context): RosettaDatabase {
return INSTANCE return INSTANCE
?: synchronized(this) { ?: synchronized(this) {
@@ -224,7 +254,8 @@ abstract class RosettaDatabase : RoomDatabase() {
MIGRATION_10_11, MIGRATION_10_11,
MIGRATION_11_12, MIGRATION_11_12,
MIGRATION_12_13, MIGRATION_12_13,
MIGRATION_13_14 MIGRATION_13_14,
MIGRATION_14_15
) )
.fallbackToDestructiveMigration() // Для разработки - только .fallbackToDestructiveMigration() // Для разработки - только
// если миграция не // если миграция не

View File

@@ -3,6 +3,7 @@ package com.rosetta.messenger.network
import android.content.Context import android.content.Context
import android.media.AudioManager import android.media.AudioManager
import android.util.Log import android.util.Log
import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import java.security.MessageDigest import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
@@ -872,13 +873,15 @@ object CallManager {
} }
sharedKeyBytes = keyBytes.copyOf(32) sharedKeyBytes = keyBytes.copyOf(32)
breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}") breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}")
// Open native diagnostics file for frame-level logging // Frame-level diagnostics are enabled only for debug builds.
try { if (BuildConfig.DEBUG) {
val dir = java.io.File(appContext!!.filesDir, "crash_reports") try {
if (!dir.exists()) dir.mkdirs() val dir = java.io.File(appContext!!.filesDir, "crash_reports")
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath if (!dir.exists()) dir.mkdirs()
XChaCha20E2EE.nativeOpenDiagFile(diagPath) val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
} catch (_: Throwable) {} XChaCha20E2EE.nativeOpenDiagFile(diagPath)
} catch (_: Throwable) {}
}
// If sender track already exists, bind encryptor now. // If sender track already exists, bind encryptor now.
val existingSender = val existingSender =
pendingAudioSenderForE2ee pendingAudioSenderForE2ee

View File

@@ -4641,6 +4641,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
encryptedPlainMessage, // 🔒 Зашифрованный текст для хранения в encryptedPlainMessage, // 🔒 Зашифрованный текст для хранения в
// БД // БД
attachments = attachmentsJson, attachments = attachmentsJson,
primaryAttachmentType =
resolvePrimaryAttachmentTypeFromJson(attachmentsJson),
replyToMessageId = null, replyToMessageId = null,
dialogKey = dialogKey dialogKey = dialogKey
) )
@@ -4649,6 +4651,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} catch (e: Exception) {} } catch (e: Exception) {}
} }
private fun resolvePrimaryAttachmentTypeFromJson(attachmentsJson: String): Int {
if (attachmentsJson.isBlank() || attachmentsJson == "[]") return -1
return try {
val array = JSONArray(attachmentsJson)
if (array.length() == 0) return -1
val first = array.optJSONObject(0) ?: return -1
first.optInt("type", -1)
} catch (_: Throwable) {
-1
}
}
private fun showTypingIndicator() { private fun showTypingIndicator() {
_opponentTyping.value = true _opponentTyping.value = true
// Отменяем предыдущий таймер, чтобы избежать race condition // Отменяем предыдущий таймер, чтобы избежать race condition

View File

@@ -666,6 +666,7 @@ fun ChatsListScreen(
// Requests count for badge on hamburger & sidebar // Requests count for badge on hamburger & sidebar
val topLevelChatsState by chatsViewModel.chatsState.collectAsState() val topLevelChatsState by chatsViewModel.chatsState.collectAsState()
val topLevelIsLoading by chatsViewModel.isLoading.collectAsState()
val topLevelRequestsCount = topLevelChatsState.requestsCount val topLevelRequestsCount = topLevelChatsState.requestsCount
// Dev console dialog - commented out for now // Dev console dialog - commented out for now
@@ -1934,8 +1935,8 @@ fun ChatsListScreen(
// Это предотвращает "дергание" UI когда dialogs и requests // Это предотвращает "дергание" UI когда dialogs и requests
// обновляются // обновляются
// независимо // независимо
val chatsState by chatsViewModel.chatsState.collectAsState() val chatsState = topLevelChatsState
val isLoading by chatsViewModel.isLoading.collectAsState() val isLoading = topLevelIsLoading
val requests = chatsState.requests val requests = chatsState.requests
val requestsCount = chatsState.requestsCount val requestsCount = chatsState.requestsCount
@@ -2061,64 +2062,15 @@ fun ChatsListScreen(
label = "CallsTransition" label = "CallsTransition"
) { isCallsScreen -> ) { isCallsScreen ->
if (isCallsScreen) { if (isCallsScreen) {
Box( CallsRouteContent(
modifier = Modifier isDarkTheme = isDarkTheme,
.fillMaxSize() accountPublicKey = accountPublicKey,
.pointerInput(Unit) { avatarRepository = avatarRepository,
val velocityTracker = androidx.compose.ui.input.pointer.util.VelocityTracker() onUserSelect = onUserSelect,
awaitEachGesture { onStartCall = onStartCall,
val down = awaitFirstDown(requireUnconsumed = false) onStartNewCall = onSearchClick,
velocityTracker.resetTracking() onBack = { setInlineCallsVisible(false) }
velocityTracker.addPosition(down.uptimeMillis, down.position) )
var totalDragX = 0f
var totalDragY = 0f
var claimed = false
val touchSlop = viewConfiguration.touchSlop * 0.6f
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (change.changedToUpIgnoreConsumed()) break
val delta = change.positionChange()
totalDragX += delta.x
totalDragY += delta.y
velocityTracker.addPosition(change.uptimeMillis, change.position)
if (!claimed) {
val distance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
if (distance < touchSlop) continue
if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.2f) {
claimed = true
change.consume()
} else {
break
}
} else {
change.consume()
}
}
if (claimed) {
val velocityX = velocityTracker.calculateVelocity().x
val screenWidth = size.width.toFloat()
if (totalDragX > screenWidth * 0.08f || velocityX > 200f) {
setInlineCallsVisible(false)
}
}
}
}
) {
CallsHistoryScreen(
isDarkTheme = isDarkTheme,
accountPublicKey = accountPublicKey,
avatarRepository = avatarRepository,
onOpenChat = onUserSelect,
onStartCall = onStartCall,
onStartNewCall = onSearchClick,
modifier = Modifier.fillMaxSize()
)
}
} else { } else {
// 🎬 Animated content transition between main list and // 🎬 Animated content transition between main list and
// requests // requests
@@ -2154,110 +2106,42 @@ fun ChatsListScreen(
label = "RequestsTransition" label = "RequestsTransition"
) { isRequestsScreen -> ) { isRequestsScreen ->
if (isRequestsScreen) { if (isRequestsScreen) {
// 📬 Show Requests Screen with swipe-back RequestsRouteContent(
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
val velocityTracker = androidx.compose.ui.input.pointer.util.VelocityTracker()
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
velocityTracker.resetTracking()
velocityTracker.addPosition(down.uptimeMillis, down.position)
var totalDragX = 0f
var totalDragY = 0f
var claimed = false
val touchSlop = viewConfiguration.touchSlop * 0.6f
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (change.changedToUpIgnoreConsumed()) break
val delta = change.positionChange()
totalDragX += delta.x
totalDragY += delta.y
velocityTracker.addPosition(change.uptimeMillis, change.position)
if (!claimed) {
val distance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
if (distance < touchSlop) continue
if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.2f) {
claimed = true
change.consume()
} else {
break
}
} else {
change.consume()
}
}
if (claimed) {
val velocityX = velocityTracker.calculateVelocity().x
val screenWidth = size.width.toFloat()
if (totalDragX > screenWidth * 0.08f || velocityX > 200f) {
setInlineRequestsVisible(
false
)
}
}
}
}
) {
RequestsScreen(
requests = requests, requests = requests,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onBack = { avatarRepository = avatarRepository,
setInlineRequestsVisible(
false
)
},
onRequestClick = { request ->
val user =
chatsViewModel
.dialogToSearchUser(
request
)
onUserSelect(user)
},
avatarRepository =
avatarRepository,
blockedUsers = blockedUsers, blockedUsers = blockedUsers,
pinnedChats = pinnedChats, pinnedChats = pinnedChats,
isDrawerOpen = isDrawerOpen =
drawerState.isOpen || drawerState.isOpen ||
drawerState drawerState.isAnimationRunning,
.isAnimationRunning, onTogglePin = onTogglePin,
onTogglePin = { opponentKey ->
onTogglePin(opponentKey)
},
onDeleteDialog = { opponentKey -> onDeleteDialog = { opponentKey ->
scope.launch { scope.launch {
chatsViewModel chatsViewModel.deleteDialog(opponentKey)
.deleteDialog(
opponentKey
)
} }
}, },
onBlockUser = { opponentKey -> onBlockUser = { opponentKey ->
scope.launch { scope.launch {
chatsViewModel chatsViewModel.blockUser(opponentKey)
.blockUser(
opponentKey
)
} }
}, },
onUnblockUser = { opponentKey -> onUnblockUser = { opponentKey ->
scope.launch { scope.launch {
chatsViewModel chatsViewModel.unblockUser(opponentKey)
.unblockUser(
opponentKey
)
} }
},
onRequestClick = { request ->
val user =
chatsViewModel.dialogToSearchUser(
request
)
onUserSelect(user)
},
onBack = {
setInlineRequestsVisible(false)
} }
) )
} // Close Box wrapper
} else if (showSkeleton) { } else if (showSkeleton) {
ChatsListSkeleton(isDarkTheme = isDarkTheme) ChatsListSkeleton(isDarkTheme = isDarkTheme)
} else if (isLoading && chatsState.isEmpty) { } else if (isLoading && chatsState.isEmpty) {
@@ -4919,6 +4803,135 @@ fun TypingIndicatorSmall() {
} }
} }
@Composable
private fun SwipeBackContainer(
onBack: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit
) {
Box(
modifier =
modifier.fillMaxSize().pointerInput(onBack) {
val velocityTracker = VelocityTracker()
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
velocityTracker.resetTracking()
velocityTracker.addPosition(down.uptimeMillis, down.position)
var totalDragX = 0f
var totalDragY = 0f
var claimed = false
val touchSlop = viewConfiguration.touchSlop * 0.6f
while (true) {
val event = awaitPointerEvent()
val change =
event.changes.firstOrNull { it.id == down.id }
?: break
if (change.changedToUpIgnoreConsumed()) break
val delta = change.positionChange()
totalDragX += delta.x
totalDragY += delta.y
velocityTracker.addPosition(
change.uptimeMillis,
change.position
)
if (!claimed) {
val distance =
kotlin.math.sqrt(
totalDragX * totalDragX +
totalDragY * totalDragY
)
if (distance < touchSlop) continue
if (
totalDragX > 0 &&
kotlin.math.abs(totalDragX) >
kotlin.math.abs(totalDragY) * 1.2f
) {
claimed = true
change.consume()
} else {
break
}
} else {
change.consume()
}
}
if (claimed) {
val velocityX = velocityTracker.calculateVelocity().x
val screenWidth = size.width.toFloat()
if (
totalDragX > screenWidth * 0.08f ||
velocityX > 200f
) {
onBack()
}
}
}
}
) {
content()
}
}
@Composable
private fun CallsRouteContent(
isDarkTheme: Boolean,
accountPublicKey: String,
avatarRepository: AvatarRepository?,
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit,
onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit,
onStartNewCall: () -> Unit,
onBack: () -> Unit
) {
SwipeBackContainer(onBack = onBack) {
CallsHistoryScreen(
isDarkTheme = isDarkTheme,
accountPublicKey = accountPublicKey,
avatarRepository = avatarRepository,
onOpenChat = onUserSelect,
onStartCall = onStartCall,
onStartNewCall = onStartNewCall,
modifier = Modifier.fillMaxSize()
)
}
}
@Composable
private fun RequestsRouteContent(
requests: List<DialogUiModel>,
isDarkTheme: Boolean,
avatarRepository: AvatarRepository?,
blockedUsers: Set<String>,
pinnedChats: Set<String>,
isDrawerOpen: Boolean,
onTogglePin: (String) -> Unit,
onDeleteDialog: (String) -> Unit,
onBlockUser: (String) -> Unit,
onUnblockUser: (String) -> Unit,
onRequestClick: (DialogUiModel) -> Unit,
onBack: () -> Unit
) {
SwipeBackContainer(onBack = onBack) {
RequestsScreen(
requests = requests,
isDarkTheme = isDarkTheme,
onBack = onBack,
onRequestClick = onRequestClick,
avatarRepository = avatarRepository,
blockedUsers = blockedUsers,
pinnedChats = pinnedChats,
isDrawerOpen = isDrawerOpen,
onTogglePin = onTogglePin,
onDeleteDialog = onDeleteDialog,
onBlockUser = onBlockUser,
onUnblockUser = onUnblockUser
)
}
}
/** 📬 Секция Requests — Telegram Archived Chats style */ /** 📬 Секция Requests — Telegram Archived Chats style */
@Composable @Composable
fun RequestsSection( fun RequestsSection(