fix: optimize message decryption and caching in ChatsListViewModel and CryptoManager
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 // 🔗 Ссылки не кликабельны в списке чатов
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user