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

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

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

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

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

View File

@@ -17,10 +17,11 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
Защищенные звонки и диагностика E2EE
- Обновлен custom WebRTC для Android и исправлена совместимость аудио E2EE с Desktop
- Улучшены diagnostics для шифрования звонков (детализация ENC/DEC в crash reports)
- В Crash Reports добавлена кнопка копирования полного лога одним действием
Оптимизация E2EE и списка чатов
- В release отключена frame-диагностика E2EE (детальные frame-логи только в debug)
- Упрощен ChatsListScreen: убрано дублирование collectAsState и вынесены route-компоненты
- Ускорены выборки по вложениям: добавлен denormalized attachment type + индекс в БД
- Добавлена миграция БД с backfill типа вложения для старых сообщений
""".trimIndent()
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", "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(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@@ -99,6 +100,8 @@ 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 // Ключ диалога для быстрой выборки
@@ -545,8 +548,10 @@ interface MessageDao {
"""
SELECT * FROM messages
WHERE account = :account
AND attachments != '[]'
AND attachments LIKE '%"type":0%'
AND (
primary_attachment_type = 0
OR (primary_attachment_type = -1 AND attachments != '[]' AND attachments LIKE '%"type":0%')
)
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
@@ -561,8 +566,10 @@ interface MessageDao {
"""
SELECT * FROM messages
WHERE account = :account
AND attachments != '[]'
AND attachments LIKE '%"type":2%'
AND (
primary_attachment_type = 2
OR (primary_attachment_type = -1 AND attachments != '[]' AND attachments LIKE '%"type":2%')
)
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
@@ -593,8 +600,14 @@ interface MessageDao {
ELSE m.from_public_key
END
WHERE m.account = :account
AND m.attachments != '[]'
AND (m.attachments LIKE '%"type":4%' OR m.attachments LIKE '%"type": 4%')
AND (
m.primary_attachment_type = 4
OR (
m.primary_attachment_type = -1
AND m.attachments != '[]'
AND (m.attachments LIKE '%"type":4%' OR m.attachments LIKE '%"type": 4%')
)
)
ORDER BY m.timestamp DESC, m.message_id DESC
LIMIT :limit
"""
@@ -611,8 +624,14 @@ interface MessageDao {
END AS peer_key
FROM messages
WHERE account = :account
AND attachments != '[]'
AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%')
AND (
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>
@@ -622,8 +641,14 @@ interface MessageDao {
"""
DELETE FROM messages
WHERE account = :account
AND attachments != '[]'
AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%')
AND (
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

View File

@@ -18,7 +18,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
AccountSyncTimeEntity::class,
GroupEntity::class,
PinnedMessageEntity::class],
version = 14,
version = 15,
exportSchema = false
)
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 {
return INSTANCE
?: synchronized(this) {
@@ -224,7 +254,8 @@ abstract class RosettaDatabase : RoomDatabase() {
MIGRATION_10_11,
MIGRATION_11_12,
MIGRATION_12_13,
MIGRATION_13_14
MIGRATION_13_14,
MIGRATION_14_15
)
.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
@@ -872,13 +873,15 @@ object CallManager {
}
sharedKeyBytes = keyBytes.copyOf(32)
breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}")
// Open native diagnostics file for frame-level logging
try {
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
XChaCha20E2EE.nativeOpenDiagFile(diagPath)
} catch (_: Throwable) {}
// Frame-level diagnostics are enabled only for debug builds.
if (BuildConfig.DEBUG) {
try {
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
XChaCha20E2EE.nativeOpenDiagFile(diagPath)
} catch (_: Throwable) {}
}
// If sender track already exists, bind encryptor now.
val existingSender =
pendingAudioSenderForE2ee

View File

@@ -4641,6 +4641,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
encryptedPlainMessage, // 🔒 Зашифрованный текст для хранения в
// БД
attachments = attachmentsJson,
primaryAttachmentType =
resolvePrimaryAttachmentTypeFromJson(attachmentsJson),
replyToMessageId = null,
dialogKey = dialogKey
)
@@ -4649,6 +4651,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} 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

@@ -666,6 +666,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
@@ -1934,8 +1935,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
@@ -2061,64 +2062,15 @@ fun ChatsListScreen(
label = "CallsTransition"
) { isCallsScreen ->
if (isCallsScreen) {
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) {
setInlineCallsVisible(false)
}
}
}
}
) {
CallsHistoryScreen(
isDarkTheme = isDarkTheme,
accountPublicKey = accountPublicKey,
avatarRepository = avatarRepository,
onOpenChat = onUserSelect,
onStartCall = onStartCall,
onStartNewCall = onSearchClick,
modifier = Modifier.fillMaxSize()
)
}
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
@@ -2154,110 +2106,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) {
@@ -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 */
@Composable
fun RequestsSection(