fix: optimize message decryption and caching in ChatsListViewModel and CryptoManager

This commit is contained in:
2026-02-05 00:28:52 +05:00
parent 54c5f015bb
commit e307e8d35d
6 changed files with 244 additions and 160 deletions

View File

@@ -34,6 +34,21 @@ object CryptoManager {
private val keyPairCache = mutableMapOf<String, KeyPairData>() private val keyPairCache = mutableMapOf<String, KeyPairData>()
private val privateKeyHashCache = mutableMapOf<String, String>() private val privateKeyHashCache = mutableMapOf<String, String>()
// 🚀 КРИТИЧЕСКАЯ ОПТИМИЗАЦИЯ: Кэш для PBKDF2 производных ключей
// PBKDF2 с 1000 итерациями - очень тяжелая операция!
// Кэшируем производный ключ для каждого пароля чтобы не вычислять его каждый раз
private val pbkdf2KeyCache = mutableMapOf<String, SecretKeySpec>()
// 🚀 ОПТИМИЗАЦИЯ: LRU-кэш для расшифрованных сообщений
// Ключ = encryptedData, Значение = расшифрованный текст
// Ограничиваем размер чтобы не съесть память
private const val DECRYPTION_CACHE_SIZE = 500
private val decryptionCache = object : LinkedHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?): Boolean {
return size > DECRYPTION_CACHE_SIZE
}
}
init { init {
// Add BouncyCastle provider for secp256k1 support // Add BouncyCastle provider for secp256k1 support
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
@@ -41,6 +56,30 @@ object CryptoManager {
} }
} }
/**
* 🚀 Получить или вычислить PBKDF2 ключ для пароля (кэшируется)
*/
private fun getPbkdf2Key(password: String): SecretKeySpec {
return pbkdf2KeyCache.getOrPut(password) {
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val spec = PBEKeySpec(password.toCharArray(), SALT.toByteArray(Charsets.UTF_8), PBKDF2_ITERATIONS, KEY_SIZE)
val secretKey = factory.generateSecret(spec)
SecretKeySpec(secretKey.encoded, "AES")
}
}
/**
* 🧹 Очистить кэши при logout
*/
fun clearCaches() {
pbkdf2KeyCache.clear()
synchronized(decryptionCache) {
decryptionCache.clear()
}
keyPairCache.clear()
privateKeyHashCache.clear()
}
/** /**
* Generate a new 12-word BIP39 seed phrase * Generate a new 12-word BIP39 seed phrase
*/ */
@@ -252,9 +291,40 @@ object CryptoManager {
* - Supports old format (base64-encoded hex "iv:ciphertext") * - Supports old format (base64-encoded hex "iv:ciphertext")
* - Supports new format (base64 "iv:ciphertext") * - Supports new format (base64 "iv:ciphertext")
* - Supports chunked format ("CHNK:" + chunks joined by "::") * - Supports chunked format ("CHNK:" + chunks joined by "::")
*
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
*/ */
fun decryptWithPassword(encryptedData: String, password: String): String? { fun decryptWithPassword(encryptedData: String, password: String): String? {
// 🚀 ОПТИМИЗАЦИЯ: Проверяем кэш расшифрованных сообщений
val cacheKey = "$password:$encryptedData"
synchronized(decryptionCache) {
decryptionCache[cacheKey]?.let { return it }
}
return try { return try {
val result = decryptWithPasswordInternal(encryptedData, password)
// 🚀 Сохраняем в кэш
if (result != null) {
synchronized(decryptionCache) {
decryptionCache[cacheKey] = result
}
}
result
} catch (e: Exception) {
null
}
}
/**
* 🔐 Внутренняя функция расшифровки (без кэширования результата)
*/
private fun decryptWithPasswordInternal(encryptedData: String, password: String): String? {
return try {
// 🚀 Получаем кэшированный PBKDF2 ключ
val key = getPbkdf2Key(password)
// Check for old format: base64-encoded string containing hex // Check for old format: base64-encoded string containing hex
if (isOldFormat(encryptedData)) { if (isOldFormat(encryptedData)) {
val decoded = String(Base64.decode(encryptedData, Base64.NO_WRAP), Charsets.UTF_8) val decoded = String(Base64.decode(encryptedData, Base64.NO_WRAP), Charsets.UTF_8)
@@ -264,13 +334,7 @@ object CryptoManager {
val iv = parts[0].chunked(2).map { it.toInt(16).toByte() }.toByteArray() val iv = parts[0].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val ciphertext = parts[1].chunked(2).map { it.toInt(16).toByte() }.toByteArray() val ciphertext = parts[1].chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// Derive key using PBKDF2-HMAC-SHA1 // Decrypt with AES-256-CBC (используем кэшированный ключ!)
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val spec = PBEKeySpec(password.toCharArray(), SALT.toByteArray(Charsets.UTF_8), PBKDF2_ITERATIONS, KEY_SIZE)
val secretKey = factory.generateSecret(spec)
val key = SecretKeySpec(secretKey.encoded, "AES")
// Decrypt with AES-256-CBC
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext) val decrypted = cipher.doFinal(ciphertext)
@@ -290,13 +354,7 @@ object CryptoManager {
val iv = Base64.decode(parts[0], Base64.NO_WRAP) val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP) val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
// Derive key using PBKDF2-HMAC-SHA1 // Decrypt with AES-256-CBC (используем кэшированный ключ!)
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val spec = PBEKeySpec(password.toCharArray(), SALT.toByteArray(Charsets.UTF_8), PBKDF2_ITERATIONS, KEY_SIZE)
val secretKey = factory.generateSecret(spec)
val key = SecretKeySpec(secretKey.encoded, "AES")
// Decrypt with AES-256-CBC
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext) val decrypted = cipher.doFinal(ciphertext)
@@ -318,13 +376,7 @@ object CryptoManager {
val iv = Base64.decode(parts[0], Base64.NO_WRAP) val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP) val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
// Derive key using PBKDF2-HMAC-SHA1 (⚠️ SHA1, не SHA256!) // Decrypt with AES-256-CBC (используем кэшированный ключ!)
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val spec = PBEKeySpec(password.toCharArray(), SALT.toByteArray(Charsets.UTF_8), PBKDF2_ITERATIONS, KEY_SIZE)
val secretKey = factory.generateSecret(spec)
val key = SecretKeySpec(secretKey.encoded, "AES")
// Decrypt with AES-256-CBC
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext) val decrypted = cipher.doFinal(ciphertext)

View File

@@ -1531,7 +1531,11 @@ val newList = messages + optimisticMessages
val text = caption.trim() val text = caption.trim()
val attachmentId = "img_$timestamp" val attachmentId = "img_$timestamp"
// 1. 🚀 МГНОВЕННО показываем optimistic сообщение с localUri // 🔥 КРИТИЧНО: Получаем размеры СРАЗУ (быстрая операция - только читает заголовок файла)
// Это предотвращает "расширение" пузырька при первом показе
val (imageWidth, imageHeight) = com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri)
// 1. 🚀 МГНОВЕННО показываем optimistic сообщение с localUri И РАЗМЕРАМИ
// Используем URI напрямую для отображения (без конвертации в base64) // Используем URI напрямую для отображения (без конвертации в base64)
val optimisticMessage = ChatMessage( val optimisticMessage = ChatMessage(
id = messageId, id = messageId,
@@ -1545,8 +1549,8 @@ val newList = messages + optimisticMessages
blob = "", // Пока пустой, обновим после конвертации blob = "", // Пока пустой, обновим после конвертации
type = AttachmentType.IMAGE, type = AttachmentType.IMAGE,
preview = "", // Пока пустой, обновим после генерации preview = "", // Пока пустой, обновим после генерации
width = 0, width = imageWidth, // 🔥 Используем реальные размеры сразу!
height = 0, height = imageHeight, // 🔥 Используем реальные размеры сразу!
localUri = imageUri.toString() // 🔥 Используем localUri для мгновенного показа localUri = imageUri.toString() // 🔥 Используем localUri для мгновенного показа
) )
) )
@@ -1558,15 +1562,15 @@ val newList = messages + optimisticMessages
// Чтобы при выходе из диалога сообщение не пропадало // Чтобы при выходе из диалога сообщение не пропадало
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
// Сохраняем с localUri в attachments для восстановления при возврате в чат // Сохраняем с localUri и размерами в attachments для восстановления при возврате в чат
val attachmentsJson = JSONArray().apply { val attachmentsJson = JSONArray().apply {
put(JSONObject().apply { put(JSONObject().apply {
put("id", attachmentId) put("id", attachmentId)
put("type", AttachmentType.IMAGE.value) put("type", AttachmentType.IMAGE.value)
put("preview", "") put("preview", "")
put("blob", "") put("blob", "")
put("width", 0) put("width", imageWidth) // 🔥 Сохраняем размеры в БД
put("height", 0) put("height", imageHeight) // 🔥 Сохраняем размеры в БД
put("localUri", imageUri.toString()) // 🔥 Сохраняем localUri put("localUri", imageUri.toString()) // 🔥 Сохраняем localUri
}) })
}.toString() }.toString()

View File

@@ -1318,7 +1318,8 @@ fun ChatItem(
color = secondaryTextColor, color = secondaryTextColor,
maxLines = 1, maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END, overflow = android.text.TextUtils.TruncateAt.END,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
enableLinks = false // 🔗 Ссылки не кликабельны в списке чатов
) )
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -1941,7 +1942,8 @@ fun DialogItemContent(
else FontWeight.Normal, else FontWeight.Normal,
maxLines = 1, maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END, overflow = android.text.TextUtils.TruncateAt.END,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
enableLinks = false // 🔗 Ссылки не кликабельны в списке чатов
) )
} }

View File

@@ -13,8 +13,11 @@ import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** /**
* UI модель диалога с расшифрованным lastMessage * UI модель диалога с расшифрованным lastMessage
@@ -121,76 +124,78 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
dialogDao.getDialogsFlow(publicKey) dialogDao.getDialogsFlow(publicKey)
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
.map { dialogsList -> .map { dialogsList ->
// 🔓 Расшифровываем lastMessage на IO потоке (PBKDF2 - тяжелая операция!) // <EFBFBD> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений
dialogsList.map { dialog -> withContext(Dispatchers.Default) {
// 🔥 Загружаем информацию о пользователе если её нет dialogsList.map { dialog ->
// 📁 НЕ загружаем для Saved Messages async {
val isSavedMessages = (dialog.account == dialog.opponentKey) // 🔥 Загружаем информацию о пользователе если её нет
if (!isSavedMessages && (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey || dialog.opponentTitle == dialog.opponentKey.take(7))) { // 📁 НЕ загружаем для Saved Messages
loadUserInfoForDialog(dialog.opponentKey) val isSavedMessages = (dialog.account == dialog.opponentKey)
} if (!isSavedMessages && (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey || dialog.opponentTitle == dialog.opponentKey.take(7))) {
loadUserInfoForDialog(dialog.opponentKey)
val decryptedLastMessage = try { }
if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) {
CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey) // 🚀 Расшифровка теперь кэшируется в CryptoManager!
?: dialog.lastMessage val decryptedLastMessage = try {
} else { if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) {
dialog.lastMessage CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey)
} ?: dialog.lastMessage
} catch (e: Exception) { } else {
dialog.lastMessage // Fallback на зашифрованный текст dialog.lastMessage
}
// 🔥🔥🔥 НОВЫЙ ПОДХОД: Получаем статус НАПРЯМУЮ из таблицы messages
// Это гарантирует синхронизацию с тем что показывается в диалоге
val lastMsgStatus = messageDao.getLastMessageStatus(publicKey, dialog.opponentKey)
val actualFromMe = lastMsgStatus?.fromMe ?: 0
val actualDelivered = if (actualFromMe == 1) (lastMsgStatus?.delivered ?: 0) else 0
val actualRead = if (actualFromMe == 1) (lastMsgStatus?.read ?: 0) else 0
// 📎 Определяем тип attachment последнего сообщения
val attachmentType = try {
val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey)
if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") {
val attachments = org.json.JSONArray(attachmentsJson)
if (attachments.length() > 0) {
val firstAttachment = attachments.getJSONObject(0)
val type = firstAttachment.optInt("type", -1)
when (type) {
0 -> "Photo" // AttachmentType.IMAGE = 0
2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3
else -> null
} }
} else null } catch (e: Exception) {
} else null dialog.lastMessage // Fallback на зашифрованный текст
} catch (e: Exception) { }
null
} // 🔥🔥🔥 НОВЫЙ ПОДХОД: Получаем статус НАПРЯМУЮ из таблицы messages
// Это гарантирует синхронизацию с тем что показывается в диалоге
// 🔥 Лог для отладки - показываем и старые и новые значения val lastMsgStatus = messageDao.getLastMessageStatus(publicKey, dialog.opponentKey)
val actualFromMe = lastMsgStatus?.fromMe ?: 0
DialogUiModel( val actualDelivered = if (actualFromMe == 1) (lastMsgStatus?.delivered ?: 0) else 0
id = dialog.id, val actualRead = if (actualFromMe == 1) (lastMsgStatus?.read ?: 0) else 0
account = dialog.account,
opponentKey = dialog.opponentKey, // 📎 Определяем тип attachment последнего сообщения
opponentTitle = dialog.opponentTitle, val attachmentType = try {
opponentUsername = dialog.opponentUsername, val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey)
lastMessage = decryptedLastMessage, if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") {
lastMessageTimestamp = dialog.lastMessageTimestamp, val attachments = org.json.JSONArray(attachmentsJson)
unreadCount = dialog.unreadCount, if (attachments.length() > 0) {
isOnline = dialog.isOnline, val firstAttachment = attachments.getJSONObject(0)
lastSeen = dialog.lastSeen, val type = firstAttachment.optInt("type", -1)
verified = dialog.verified, when (type) {
isSavedMessages = isSavedMessages, // 📁 Saved Messages 0 -> "Photo" // AttachmentType.IMAGE = 0
lastMessageFromMe = actualFromMe, // 🔥 Используем актуальные данные из messages 2 -> "File" // AttachmentType.FILE = 2
lastMessageDelivered = actualDelivered, // 🔥 Используем актуальные данные из messages 3 -> "Avatar" // AttachmentType.AVATAR = 3
lastMessageRead = actualRead, // 🔥 Используем актуальные данные из messages else -> null
lastMessageAttachmentType = attachmentType // 📎 Тип attachment }
) } else null
} else null
} catch (e: Exception) {
null
}
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 = actualFromMe, // 🔥 Используем актуальные данные из messages
lastMessageDelivered = actualDelivered, // 🔥 Используем актуальные данные из messages
lastMessageRead = actualRead, // 🔥 Используем актуальные данные из messages
lastMessageAttachmentType = attachmentType // 📎 Тип attachment
)
}
}.awaitAll()
} }
} }
.flowOn(Dispatchers.Default) // 🚀 map выполняется на Default (CPU)
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedDialogs -> .collect { decryptedDialogs ->
_dialogs.value = decryptedDialogs _dialogs.value = decryptedDialogs
@@ -209,66 +214,71 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
dialogDao.getRequestsFlow(publicKey) dialogDao.getRequestsFlow(publicKey)
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.map { requestsList -> .map { requestsList ->
requestsList.map { dialog -> // 🚀 ОПТИМИЗАЦИЯ: Параллельная расшифровка
// 🔥 Загружаем информацию о пользователе если её нет withContext(Dispatchers.Default) {
// 📁 НЕ загружаем для Saved Messages requestsList.map { dialog ->
val isSavedMessages = (dialog.account == dialog.opponentKey) async {
if (!isSavedMessages && (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey)) { // 🔥 Загружаем информацию о пользователе если её нет
loadUserInfoForRequest(dialog.opponentKey) // 📁 НЕ загружаем для Saved Messages
} val isSavedMessages = (dialog.account == dialog.opponentKey)
if (!isSavedMessages && (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey)) {
val decryptedLastMessage = try { loadUserInfoForRequest(dialog.opponentKey)
if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) { }
CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey)
?: dialog.lastMessage // 🚀 Расшифровка теперь кэшируется в CryptoManager!
} else { val decryptedLastMessage = try {
dialog.lastMessage if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) {
} CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey)
} catch (e: Exception) { ?: dialog.lastMessage
dialog.lastMessage } else {
} dialog.lastMessage
// 📎 Определяем тип attachment последнего сообщения
val attachmentType = try {
val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey)
if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") {
val attachments = org.json.JSONArray(attachmentsJson)
if (attachments.length() > 0) {
val firstAttachment = attachments.getJSONObject(0)
val type = firstAttachment.optInt("type", -1)
when (type) {
0 -> "Photo" // AttachmentType.IMAGE = 0
2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3
else -> null
} }
} else null } catch (e: Exception) {
} else null dialog.lastMessage
} catch (e: Exception) { }
null
} // 📎 Определяем тип attachment последнего сообщения
val attachmentType = try {
DialogUiModel( val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey)
id = dialog.id, if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") {
account = dialog.account, val attachments = org.json.JSONArray(attachmentsJson)
opponentKey = dialog.opponentKey, if (attachments.length() > 0) {
opponentTitle = dialog.opponentTitle, // 🔥 Показываем имя как в обычных чатах val firstAttachment = attachments.getJSONObject(0)
opponentUsername = dialog.opponentUsername, val type = firstAttachment.optInt("type", -1)
lastMessage = decryptedLastMessage, when (type) {
lastMessageTimestamp = dialog.lastMessageTimestamp, 0 -> "Photo" // AttachmentType.IMAGE = 0
unreadCount = dialog.unreadCount, 2 -> "File" // AttachmentType.FILE = 2
isOnline = dialog.isOnline, 3 -> "Avatar" // AttachmentType.AVATAR = 3
lastSeen = dialog.lastSeen, else -> null
verified = dialog.verified, }
isSavedMessages = (dialog.account == dialog.opponentKey), // 📁 Saved Messages } else null
lastMessageFromMe = dialog.lastMessageFromMe, } else null
lastMessageDelivered = dialog.lastMessageDelivered, } catch (e: Exception) {
lastMessageRead = dialog.lastMessageRead, null
lastMessageAttachmentType = attachmentType // 📎 Тип attachment }
)
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
)
}
}.awaitAll()
} }
} }
.flowOn(Dispatchers.Default)
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedRequests -> .collect { decryptedRequests ->
_requests.value = decryptedRequests _requests.value = decryptedRequests

View File

@@ -117,12 +117,24 @@ fun TelegramStyleMessageContent(
// Определяем layout // Определяем layout
val (width, height, timeX, timeY) = val (width, height, timeX, timeY) =
if (fillWidth) { if (fillWidth) {
// 🔥 Для caption - занимаем всю доступную ширину, время справа на одной линии // 🔥 Для caption - занимаем всю доступную ширину
val w = constraints.maxWidth val w = constraints.maxWidth
val h = maxOf(textPlaceable.height, timePlaceable.height)
val tX = w - timeWidth // Время справа // 🔥 ИСПРАВЛЕНИЕ: Проверяем помещается ли текст + время в одну строку
val tY = h - timePlaceable.height // Время внизу строки (выровнено по baseline) val availableForTime = w - textWidth - spacingPx
LayoutResult(w, h, tX, tY) if (availableForTime >= timeWidth) {
// Текст и время помещаются - время справа на одной линии
val h = maxOf(textPlaceable.height, timePlaceable.height)
val tX = w - timeWidth // Время справа
val tY = h - timePlaceable.height // Время внизу строки (выровнено по baseline)
LayoutResult(w, h, tX, tY)
} else {
// Текст длинный - время на новой строке справа внизу
val h = textPlaceable.height + newLineHeightPx
val tX = w - timeWidth // Время справа
val tY = h - timePlaceable.height // Время внизу
LayoutResult(w, h, tX, tY)
}
} else if (!textWraps && totalSingleLineWidth <= constraints.maxWidth) { } else if (!textWraps && totalSingleLineWidth <= constraints.maxWidth) {
// Текст и время на одной строке // Текст и время на одной строке
val w = totalSingleLineWidth val w = totalSingleLineWidth
@@ -510,7 +522,7 @@ fun MessageBubble(
} }
val bubbleWidthModifier = if (hasImageWithCaption || hasOnlyMedia) { val bubbleWidthModifier = if (hasImageWithCaption || hasOnlyMedia) {
Modifier.widthIn(max = photoWidth) // Жёстко ограничиваем ширину размером фото Modifier.width(photoWidth) // 🔥 Фиксированная ширина = размер фото (убирает лишний отступ)
} else { } else {
Modifier.widthIn(min = 60.dp, max = 280.dp) Modifier.widthIn(min = 60.dp, max = 280.dp)
} }

View File

@@ -35,6 +35,7 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -56,6 +57,7 @@ import androidx.compose.ui.graphics.graphicsLayer
* 📷 In-App Camera Screen - как в Telegram * 📷 In-App Camera Screen - как в Telegram
* Кастомная камера без системного превью, сразу переходит в ImageEditorScreen * Кастомная камера без системного превью, сразу переходит в ImageEditorScreen
*/ */
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable @Composable
fun InAppCameraScreen( fun InAppCameraScreen(
onDismiss: () -> Unit, onDismiss: () -> Unit,
@@ -65,6 +67,7 @@ fun InAppCameraScreen(
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val view = LocalView.current val view = LocalView.current
val keyboardController = LocalSoftwareKeyboardController.current
// Camera state // Camera state
var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) } var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }
@@ -81,8 +84,9 @@ fun InAppCameraScreen(
var isClosing by remember { mutableStateOf(false) } var isClosing by remember { mutableStateOf(false) }
val animationProgress = remember { Animatable(0f) } val animationProgress = remember { Animatable(0f) }
// Enter animation // Enter animation + hide keyboard
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
keyboardController?.hide()
animationProgress.animateTo( animationProgress.animateTo(
targetValue = 1f, targetValue = 1f,
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing) animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)