diff --git a/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt b/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt index 2907331..3b50ed4 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/CryptoManager.kt @@ -34,6 +34,21 @@ object CryptoManager { private val keyPairCache = mutableMapOf() private val privateKeyHashCache = mutableMapOf() + // 🚀 КРИТИЧЕСКАЯ ОПТИМИЗАЦИЯ: Кэш для PBKDF2 производных ключей + // PBKDF2 с 1000 итерациями - очень тяжелая операция! + // Кэшируем производный ключ для каждого пароля чтобы не вычислять его каждый раз + private val pbkdf2KeyCache = mutableMapOf() + + // 🚀 ОПТИМИЗАЦИЯ: LRU-кэш для расшифрованных сообщений + // Ключ = encryptedData, Значение = расшифрованный текст + // Ограничиваем размер чтобы не съесть память + private const val DECRYPTION_CACHE_SIZE = 500 + private val decryptionCache = object : LinkedHashMap(DECRYPTION_CACHE_SIZE, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > DECRYPTION_CACHE_SIZE + } + } + init { // Add BouncyCastle provider for secp256k1 support 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 */ @@ -252,9 +291,40 @@ object CryptoManager { * - Supports old format (base64-encoded hex "iv:ciphertext") * - Supports new format (base64 "iv:ciphertext") * - Supports chunked format ("CHNK:" + chunks joined by "::") + * + * 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений */ fun decryptWithPassword(encryptedData: String, password: String): String? { + // 🚀 ОПТИМИЗАЦИЯ: Проверяем кэш расшифрованных сообщений + val cacheKey = "$password:$encryptedData" + synchronized(decryptionCache) { + decryptionCache[cacheKey]?.let { return it } + } + 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 if (isOldFormat(encryptedData)) { 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 ciphertext = parts[1].chunked(2).map { it.toInt(16).toByte() }.toByteArray() - // Derive key using PBKDF2-HMAC-SHA1 - 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 + // Decrypt with AES-256-CBC (используем кэшированный ключ!) val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) val decrypted = cipher.doFinal(ciphertext) @@ -290,13 +354,7 @@ object CryptoManager { val iv = Base64.decode(parts[0], Base64.NO_WRAP) val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP) - // Derive key using PBKDF2-HMAC-SHA1 - 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 + // Decrypt with AES-256-CBC (используем кэшированный ключ!) val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) val decrypted = cipher.doFinal(ciphertext) @@ -318,13 +376,7 @@ object CryptoManager { val iv = Base64.decode(parts[0], Base64.NO_WRAP) val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP) - // Derive key using PBKDF2-HMAC-SHA1 (⚠️ SHA1, не SHA256!) - 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 + // Decrypt with AES-256-CBC (используем кэшированный ключ!) val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) val decrypted = cipher.doFinal(ciphertext) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 48a7a4e..ef08bea 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -1531,7 +1531,11 @@ val newList = messages + optimisticMessages val text = caption.trim() val attachmentId = "img_$timestamp" - // 1. 🚀 МГНОВЕННО показываем optimistic сообщение с localUri + // 🔥 КРИТИЧНО: Получаем размеры СРАЗУ (быстрая операция - только читает заголовок файла) + // Это предотвращает "расширение" пузырька при первом показе + val (imageWidth, imageHeight) = com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri) + + // 1. 🚀 МГНОВЕННО показываем optimistic сообщение с localUri И РАЗМЕРАМИ // Используем URI напрямую для отображения (без конвертации в base64) val optimisticMessage = ChatMessage( id = messageId, @@ -1545,8 +1549,8 @@ val newList = messages + optimisticMessages blob = "", // Пока пустой, обновим после конвертации type = AttachmentType.IMAGE, preview = "", // Пока пустой, обновим после генерации - width = 0, - height = 0, + width = imageWidth, // 🔥 Используем реальные размеры сразу! + height = imageHeight, // 🔥 Используем реальные размеры сразу! localUri = imageUri.toString() // 🔥 Используем localUri для мгновенного показа ) ) @@ -1558,15 +1562,15 @@ val newList = messages + optimisticMessages // Чтобы при выходе из диалога сообщение не пропадало viewModelScope.launch(Dispatchers.IO) { try { - // Сохраняем с localUri в attachments для восстановления при возврате в чат + // Сохраняем с localUri и размерами в attachments для восстановления при возврате в чат val attachmentsJson = JSONArray().apply { put(JSONObject().apply { put("id", attachmentId) put("type", AttachmentType.IMAGE.value) put("preview", "") put("blob", "") - put("width", 0) - put("height", 0) + put("width", imageWidth) // 🔥 Сохраняем размеры в БД + put("height", imageHeight) // 🔥 Сохраняем размеры в БД put("localUri", imageUri.toString()) // 🔥 Сохраняем localUri }) }.toString() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 14cb5b0..0dcf2c8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -1318,7 +1318,8 @@ fun ChatItem( color = secondaryTextColor, maxLines = 1, overflow = android.text.TextUtils.TruncateAt.END, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + enableLinks = false // 🔗 Ссылки не кликабельны в списке чатов ) Row(verticalAlignment = Alignment.CenterVertically) { @@ -1941,7 +1942,8 @@ fun DialogItemContent( else FontWeight.Normal, maxLines = 1, overflow = android.text.TextUtils.TruncateAt.END, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + enableLinks = false // 🔗 Ссылки не кликабельны в списке чатов ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index b1bd07b..e23709d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -13,8 +13,11 @@ import com.rosetta.messenger.network.PacketSearch import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * UI модель диалога с расшифрованным lastMessage @@ -121,76 +124,78 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio dialogDao.getDialogsFlow(publicKey) .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO .map { dialogsList -> - // 🔓 Расшифровываем lastMessage на IO потоке (PBKDF2 - тяжелая операция!) - dialogsList.map { dialog -> - // 🔥 Загружаем информацию о пользователе если её нет - // 📁 НЕ загружаем для Saved Messages - 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) - ?: dialog.lastMessage - } else { - dialog.lastMessage - } - } catch (e: Exception) { - dialog.lastMessage // Fallback на зашифрованный текст - } - - // 🔥🔥🔥 НОВЫЙ ПОДХОД: Получаем статус НАПРЯМУЮ из таблицы 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 + // � ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений + 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))) { + loadUserInfoForDialog(dialog.opponentKey) + } + + // 🚀 Расшифровка теперь кэшируется в CryptoManager! + val decryptedLastMessage = try { + if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) { + CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey) + ?: dialog.lastMessage + } else { + dialog.lastMessage } - } 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 - ) + } catch (e: Exception) { + dialog.lastMessage // Fallback на зашифрованный текст + } + + // 🔥🔥🔥 НОВЫЙ ПОДХОД: Получаем статус НАПРЯМУЮ из таблицы 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 + } 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() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .collect { decryptedDialogs -> _dialogs.value = decryptedDialogs @@ -209,66 +214,71 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio dialogDao.getRequestsFlow(publicKey) .flowOn(Dispatchers.IO) .map { requestsList -> - requestsList.map { dialog -> - // 🔥 Загружаем информацию о пользователе если её нет - // 📁 НЕ загружаем для Saved Messages - val isSavedMessages = (dialog.account == dialog.opponentKey) - if (!isSavedMessages && (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey)) { - loadUserInfoForRequest(dialog.opponentKey) - } - - val decryptedLastMessage = try { - if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) { - CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey) - ?: dialog.lastMessage - } else { - dialog.lastMessage - } - } catch (e: Exception) { - 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 + // 🚀 ОПТИМИЗАЦИЯ: Параллельная расшифровка + 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) + } + + // 🚀 Расшифровка теперь кэшируется в CryptoManager! + val decryptedLastMessage = try { + if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) { + CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey) + ?: dialog.lastMessage + } else { + dialog.lastMessage } - } 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 = (dialog.account == dialog.opponentKey), // 📁 Saved Messages - lastMessageFromMe = dialog.lastMessageFromMe, - lastMessageDelivered = dialog.lastMessageDelivered, - lastMessageRead = dialog.lastMessageRead, - lastMessageAttachmentType = attachmentType // 📎 Тип attachment - ) + } catch (e: Exception) { + 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 + } 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 = (dialog.account == dialog.opponentKey), // 📁 Saved Messages + lastMessageFromMe = dialog.lastMessageFromMe, + lastMessageDelivered = dialog.lastMessageDelivered, + lastMessageRead = dialog.lastMessageRead, + lastMessageAttachmentType = attachmentType // 📎 Тип attachment + ) + } + }.awaitAll() } } - .flowOn(Dispatchers.Default) .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .collect { decryptedRequests -> _requests.value = decryptedRequests diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index dfb33de..1043262 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -117,12 +117,24 @@ fun TelegramStyleMessageContent( // Определяем layout val (width, height, timeX, timeY) = if (fillWidth) { - // 🔥 Для caption - занимаем всю доступную ширину, время справа на одной линии + // 🔥 Для caption - занимаем всю доступную ширину val w = constraints.maxWidth - val h = maxOf(textPlaceable.height, timePlaceable.height) - val tX = w - timeWidth // Время справа - val tY = h - timePlaceable.height // Время внизу строки (выровнено по baseline) - LayoutResult(w, h, tX, tY) + + // 🔥 ИСПРАВЛЕНИЕ: Проверяем помещается ли текст + время в одну строку + val availableForTime = w - textWidth - spacingPx + 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) { // Текст и время на одной строке val w = totalSingleLineWidth @@ -510,7 +522,7 @@ fun MessageBubble( } val bubbleWidthModifier = if (hasImageWithCaption || hasOnlyMedia) { - Modifier.widthIn(max = photoWidth) // Жёстко ограничиваем ширину размером фото + Modifier.width(photoWidth) // 🔥 Фиксированная ширина = размер фото (убирает лишний отступ) } else { Modifier.widthIn(min = 60.dp, max = 280.dp) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt index 71cccf5..58f2d16 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat @@ -56,6 +57,7 @@ import androidx.compose.ui.graphics.graphicsLayer * 📷 In-App Camera Screen - как в Telegram * Кастомная камера без системного превью, сразу переходит в ImageEditorScreen */ +@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) @Composable fun InAppCameraScreen( onDismiss: () -> Unit, @@ -65,6 +67,7 @@ fun InAppCameraScreen( val lifecycleOwner = LocalLifecycleOwner.current val scope = rememberCoroutineScope() val view = LocalView.current + val keyboardController = LocalSoftwareKeyboardController.current // Camera state var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) } @@ -81,8 +84,9 @@ fun InAppCameraScreen( var isClosing by remember { mutableStateOf(false) } val animationProgress = remember { Animatable(0f) } - // Enter animation + // Enter animation + hide keyboard LaunchedEffect(Unit) { + keyboardController?.hide() animationProgress.animateTo( targetValue = 1f, animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)