feat: Enhance performance and usability in chat components and emoji handling
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -84,3 +84,5 @@ lint/tmp/
|
|||||||
|
|
||||||
# Mac
|
# Mac
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
CHANGELOG.md
|
||||||
@@ -18,6 +18,7 @@ import java.security.spec.PKCS8EncodedKeySpec
|
|||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.util.zip.Deflater
|
import java.util.zip.Deflater
|
||||||
import java.util.zip.Inflater
|
import java.util.zip.Inflater
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import com.google.crypto.tink.subtle.XChaCha20Poly1305
|
import com.google.crypto.tink.subtle.XChaCha20Poly1305
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,15 +40,10 @@ object CryptoManager {
|
|||||||
// Кэшируем производный ключ для каждого пароля чтобы не вычислять его каждый раз
|
// Кэшируем производный ключ для каждого пароля чтобы не вычислять его каждый раз
|
||||||
private val pbkdf2KeyCache = mutableMapOf<String, SecretKeySpec>()
|
private val pbkdf2KeyCache = mutableMapOf<String, SecretKeySpec>()
|
||||||
|
|
||||||
// 🚀 ОПТИМИЗАЦИЯ: LRU-кэш для расшифрованных сообщений
|
// 🚀 ОПТИМИЗАЦИЯ: Lock-free кэш для расшифрованных сообщений
|
||||||
// Ключ = encryptedData, Значение = расшифрованный текст
|
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной расшифровке
|
||||||
// Ограничиваем размер чтобы не съесть память
|
private const val DECRYPTION_CACHE_SIZE = 2000
|
||||||
private const val DECRYPTION_CACHE_SIZE = 500
|
private val decryptionCache = ConcurrentHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, 4)
|
||||||
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
|
||||||
@@ -73,9 +69,7 @@ object CryptoManager {
|
|||||||
*/
|
*/
|
||||||
fun clearCaches() {
|
fun clearCaches() {
|
||||||
pbkdf2KeyCache.clear()
|
pbkdf2KeyCache.clear()
|
||||||
synchronized(decryptionCache) {
|
decryptionCache.clear()
|
||||||
decryptionCache.clear()
|
|
||||||
}
|
|
||||||
keyPairCache.clear()
|
keyPairCache.clear()
|
||||||
privateKeyHashCache.clear()
|
privateKeyHashCache.clear()
|
||||||
}
|
}
|
||||||
@@ -306,20 +300,22 @@ object CryptoManager {
|
|||||||
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
|
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
|
||||||
*/
|
*/
|
||||||
fun decryptWithPassword(encryptedData: String, password: String): String? {
|
fun decryptWithPassword(encryptedData: String, password: String): String? {
|
||||||
// 🚀 ОПТИМИЗАЦИЯ: Проверяем кэш расшифрованных сообщений
|
// 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
|
||||||
val cacheKey = "$password:$encryptedData"
|
val cacheKey = "$password:$encryptedData"
|
||||||
synchronized(decryptionCache) {
|
decryptionCache[cacheKey]?.let { return it }
|
||||||
decryptionCache[cacheKey]?.let { return it }
|
|
||||||
}
|
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val result = decryptWithPasswordInternal(encryptedData, password)
|
val result = decryptWithPasswordInternal(encryptedData, password)
|
||||||
|
|
||||||
// 🚀 Сохраняем в кэш
|
// 🚀 Сохраняем в кэш (lock-free)
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
synchronized(decryptionCache) {
|
// Ограничиваем размер кэша
|
||||||
decryptionCache[cacheKey] = result
|
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
|
||||||
|
// Удаляем ~10% самых старых записей
|
||||||
|
val keysToRemove = decryptionCache.keys.take(DECRYPTION_CACHE_SIZE / 10)
|
||||||
|
keysToRemove.forEach { decryptionCache.remove(it) }
|
||||||
}
|
}
|
||||||
|
decryptionCache[cacheKey] = result
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
@@ -403,6 +399,11 @@ object CryptoManager {
|
|||||||
* Check if data is in old format (base64-encoded hex with ":")
|
* Check if data is in old format (base64-encoded hex with ":")
|
||||||
*/
|
*/
|
||||||
private fun isOldFormat(data: String): Boolean {
|
private fun isOldFormat(data: String): Boolean {
|
||||||
|
// 🚀 Fast path: new format always contains ':' at plaintext level (iv:ct)
|
||||||
|
// Old format is a single base64 blob without ':' in the encoded string
|
||||||
|
if (data.contains(':')) return false
|
||||||
|
if (data.startsWith("CHNK:")) return false
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val decoded = String(Base64.decode(data, Base64.NO_WRAP), Charsets.UTF_8)
|
val decoded = String(Base64.decode(data, Base64.NO_WRAP), Charsets.UTF_8)
|
||||||
decoded.contains(":") && decoded.split(":").all { part ->
|
decoded.contains(":") && decoded.split(":").all { part ->
|
||||||
|
|||||||
@@ -224,6 +224,9 @@ fun ChatDetailScreen(
|
|||||||
// 🖼 Состояние для multi-image editor (галерея)
|
// 🖼 Состояние для multi-image editor (галерея)
|
||||||
var pendingGalleryImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
|
var pendingGalleryImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
|
||||||
|
|
||||||
|
// Триггер для возврата фокуса на инпут после отправки фото из редактора
|
||||||
|
var inputFocusTrigger by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
// <20>📷 Camera launcher
|
// <20>📷 Camera launcher
|
||||||
val cameraLauncher =
|
val cameraLauncher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
@@ -1501,7 +1504,9 @@ fun ChatDetailScreen(
|
|||||||
opponentPublicKey =
|
opponentPublicKey =
|
||||||
user.publicKey,
|
user.publicKey,
|
||||||
myPrivateKey =
|
myPrivateKey =
|
||||||
currentUserPrivateKey
|
currentUserPrivateKey,
|
||||||
|
inputFocusTrigger =
|
||||||
|
inputFocusTrigger
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1943,6 +1948,7 @@ fun ChatDetailScreen(
|
|||||||
onMediaSelectedWithCaption = { mediaItem, caption ->
|
onMediaSelectedWithCaption = { mediaItem, caption ->
|
||||||
// 📸 Отправляем фото с caption напрямую
|
// 📸 Отправляем фото с caption напрямую
|
||||||
showMediaPicker = false
|
showMediaPicker = false
|
||||||
|
inputFocusTrigger++
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val base64 =
|
val base64 =
|
||||||
MediaUtils.uriToBase64Image(
|
MediaUtils.uriToBase64Image(
|
||||||
@@ -2253,14 +2259,19 @@ fun ChatDetailScreen(
|
|||||||
pendingCameraPhotoUri?.let { uri ->
|
pendingCameraPhotoUri?.let { uri ->
|
||||||
ImageEditorScreen(
|
ImageEditorScreen(
|
||||||
imageUri = uri,
|
imageUri = uri,
|
||||||
onDismiss = { pendingCameraPhotoUri = null },
|
onDismiss = {
|
||||||
|
pendingCameraPhotoUri = null
|
||||||
|
inputFocusTrigger++
|
||||||
|
},
|
||||||
onSave = { editedUri ->
|
onSave = { editedUri ->
|
||||||
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
|
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
|
||||||
viewModel.sendImageFromUri(editedUri, "")
|
viewModel.sendImageFromUri(editedUri, "")
|
||||||
|
showMediaPicker = false
|
||||||
},
|
},
|
||||||
onSaveWithCaption = { editedUri, caption ->
|
onSaveWithCaption = { editedUri, caption ->
|
||||||
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
|
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
|
||||||
viewModel.sendImageFromUri(editedUri, caption)
|
viewModel.sendImageFromUri(editedUri, caption)
|
||||||
|
showMediaPicker = false
|
||||||
},
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
showCaptionInput = true,
|
showCaptionInput = true,
|
||||||
@@ -2273,7 +2284,10 @@ fun ChatDetailScreen(
|
|||||||
if (pendingGalleryImages.isNotEmpty()) {
|
if (pendingGalleryImages.isNotEmpty()) {
|
||||||
MultiImageEditorScreen(
|
MultiImageEditorScreen(
|
||||||
imageUris = pendingGalleryImages,
|
imageUris = pendingGalleryImages,
|
||||||
onDismiss = { pendingGalleryImages = emptyList() },
|
onDismiss = {
|
||||||
|
pendingGalleryImages = emptyList()
|
||||||
|
inputFocusTrigger++
|
||||||
|
},
|
||||||
onSendAll = { imagesWithCaptions ->
|
onSendAll = { imagesWithCaptions ->
|
||||||
// 🚀 Мгновенный optimistic UI для каждого фото
|
// 🚀 Мгновенный optimistic UI для каждого фото
|
||||||
for (imageWithCaption in imagesWithCaptions) {
|
for (imageWithCaption in imagesWithCaptions) {
|
||||||
@@ -2282,6 +2296,7 @@ fun ChatDetailScreen(
|
|||||||
imageWithCaption.caption
|
imageWithCaption.caption
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
showMediaPicker = false
|
||||||
},
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
recipientName = user.title
|
recipientName = user.title
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import java.util.UUID
|
|||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
@@ -36,7 +38,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ChatViewModel"
|
private const val TAG = "ChatViewModel"
|
||||||
private const val PAGE_SIZE = 30
|
private const val PAGE_SIZE = 30
|
||||||
private const val DECRYPT_CHUNK_SIZE = 5 // Расшифровываем по 5 сообщений за раз
|
private const val DECRYPT_CHUNK_SIZE = 15 // Расшифровываем по 15 сообщений за раз
|
||||||
|
private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка
|
||||||
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
|
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
|
||||||
|
|
||||||
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
|
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
|
||||||
@@ -499,8 +502,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val hasForward = forwardMessages.isNotEmpty()
|
val hasForward = forwardMessages.isNotEmpty()
|
||||||
if (hasForward) {}
|
if (hasForward) {}
|
||||||
|
|
||||||
|
// 🚀 P1: Проверяем кэш ДО сброса состояния — если есть, подставляем мгновенно (без flash)
|
||||||
|
val account = myPublicKey
|
||||||
|
val cachedMessages =
|
||||||
|
if (account != null) {
|
||||||
|
val dialogKey = getDialogKey(account, publicKey)
|
||||||
|
dialogMessagesCache[dialogKey]
|
||||||
|
} else null
|
||||||
|
|
||||||
// Сбрасываем состояние
|
// Сбрасываем состояние
|
||||||
_messages.value = emptyList()
|
if (cachedMessages != null && cachedMessages.isNotEmpty()) {
|
||||||
|
// Мгновенная подстановка из кэша — пользователь не увидит пустой экран
|
||||||
|
_messages.value = cachedMessages
|
||||||
|
_isLoading.value = false
|
||||||
|
} else {
|
||||||
|
// Нет кэша — показываем skeleton вместо пустого Lottie
|
||||||
|
_isLoading.value = true
|
||||||
|
_messages.value = emptyList()
|
||||||
|
}
|
||||||
_opponentOnline.value = false
|
_opponentOnline.value = false
|
||||||
_opponentTyping.value = false
|
_opponentTyping.value = false
|
||||||
typingTimeoutJob?.cancel()
|
typingTimeoutJob?.cancel()
|
||||||
@@ -537,9 +556,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// Подписываемся на онлайн статус
|
// Подписываемся на онлайн статус
|
||||||
subscribeToOnlineStatus()
|
subscribeToOnlineStatus()
|
||||||
|
|
||||||
// 🔥 ОПТИМИЗАЦИЯ: Загружаем сообщения ПОСЛЕ задержки для плавной анимации
|
// <EFBFBD> P1.2: Загружаем сообщения СРАЗУ — параллельно с анимацией SwipeBackContainer
|
||||||
// 250ms - это время анимации перехода в чат
|
loadMessagesFromDatabase(delayMs = 0L)
|
||||||
loadMessagesFromDatabase(delayMs = 250L)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -651,19 +669,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
hasMoreMessages = entities.size >= PAGE_SIZE
|
hasMoreMessages = entities.size >= PAGE_SIZE
|
||||||
currentOffset = entities.size
|
currentOffset = entities.size
|
||||||
|
|
||||||
// 🔥 ЧАНКОВАЯ расшифровка - по DECRYPT_CHUNK_SIZE сообщений с yield между
|
// <EFBFBD> P2.2: ПАРАЛЛЕЛЬНАЯ расшифровка с Semaphore
|
||||||
// ними
|
// Запускаем до DECRYPT_PARALLELISM одновременных расшифровок
|
||||||
// Это предотвращает блокировку UI thread
|
|
||||||
val messages = ArrayList<ChatMessage>(entities.size)
|
|
||||||
val reversedEntities = entities.asReversed()
|
val reversedEntities = entities.asReversed()
|
||||||
for ((index, entity) in reversedEntities.withIndex()) {
|
val semaphore = Semaphore(DECRYPT_PARALLELISM)
|
||||||
val chatMsg = entityToChatMessage(entity)
|
val messages = coroutineScope {
|
||||||
messages.add(chatMsg)
|
reversedEntities
|
||||||
|
.map { entity ->
|
||||||
// Каждые DECRYPT_CHUNK_SIZE сообщений даём UI thread "подышать"
|
async {
|
||||||
if ((index + 1) % DECRYPT_CHUNK_SIZE == 0) {
|
semaphore.withPermit { entityToChatMessage(entity) }
|
||||||
yield() // Позволяем другим корутинам выполниться
|
}
|
||||||
}
|
}
|
||||||
|
.awaitAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 Сохраняем в кэш для мгновенной повторной загрузки!
|
// 🔥 Сохраняем в кэш для мгновенной повторной загрузки!
|
||||||
@@ -752,9 +769,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val newEntities = entities.filter { it.messageId !in existingIds }
|
val newEntities = entities.filter { it.messageId !in existingIds }
|
||||||
|
|
||||||
if (newEntities.isNotEmpty()) {
|
if (newEntities.isNotEmpty()) {
|
||||||
val newMessages = ArrayList<ChatMessage>(newEntities.size)
|
val semaphore = Semaphore(DECRYPT_PARALLELISM)
|
||||||
for (entity in newEntities) {
|
val newMessages = coroutineScope {
|
||||||
newMessages.add(entityToChatMessage(entity))
|
newEntities
|
||||||
|
.map { entity ->
|
||||||
|
async { semaphore.withPermit { entityToChatMessage(entity) } }
|
||||||
|
}
|
||||||
|
.awaitAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 ДОБАВЛЯЕМ новые к текущим, а не заменяем!
|
// 🔥 ДОБАВЛЯЕМ новые к текущим, а не заменяем!
|
||||||
@@ -840,8 +861,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
currentOffset += entities.size
|
currentOffset += entities.size
|
||||||
|
|
||||||
if (entities.isNotEmpty()) {
|
if (entities.isNotEmpty()) {
|
||||||
|
val semaphore = Semaphore(DECRYPT_PARALLELISM)
|
||||||
val newMessages =
|
val newMessages =
|
||||||
entities.map { entity -> entityToChatMessage(entity) }.asReversed()
|
coroutineScope {
|
||||||
|
entities
|
||||||
|
.map { entity ->
|
||||||
|
async {
|
||||||
|
semaphore.withPermit {
|
||||||
|
entityToChatMessage(entity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.awaitAll()
|
||||||
|
}
|
||||||
|
.asReversed()
|
||||||
|
|
||||||
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш при загрузке старых сообщений!
|
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш при загрузке старых сообщений!
|
||||||
// Это предотвращает потерю сообщений при повторном открытии диалога
|
// Это предотвращает потерю сообщений при повторном открытии диалога
|
||||||
@@ -876,58 +909,81 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
*/
|
*/
|
||||||
private suspend fun entityToChatMessage(entity: MessageEntity): ChatMessage {
|
private suspend fun entityToChatMessage(entity: MessageEntity): ChatMessage {
|
||||||
|
|
||||||
|
// 🚀 P2.1: Проверяем кэш расшифрованного текста по messageId
|
||||||
|
val cachedText = decryptionCache[entity.messageId]
|
||||||
|
|
||||||
// Расшифровываем сообщение из content + chachaKey
|
// Расшифровываем сообщение из content + chachaKey
|
||||||
|
var plainKeyAndNonce: ByteArray? = null // 🚀 P2.3: Сохраняем для reply расшифровки
|
||||||
var displayText =
|
var displayText =
|
||||||
try {
|
if (cachedText != null) {
|
||||||
val privateKey = myPrivateKey
|
cachedText
|
||||||
if (privateKey != null &&
|
} else
|
||||||
entity.content.isNotEmpty() &&
|
try {
|
||||||
entity.chachaKey.isNotEmpty()
|
val privateKey = myPrivateKey
|
||||||
) {
|
if (privateKey != null &&
|
||||||
// Расшифровываем как в архиве: content + chachaKey + privateKey
|
entity.content.isNotEmpty() &&
|
||||||
val decrypted =
|
entity.chachaKey.isNotEmpty()
|
||||||
MessageCrypto.decryptIncoming(
|
) {
|
||||||
ciphertext = entity.content,
|
// Расшифровываем как в архиве: content + chachaKey + privateKey
|
||||||
encryptedKey = entity.chachaKey,
|
// 🚀 Используем Full версию чтобы получить plainKeyAndNonce для
|
||||||
myPrivateKey = privateKey
|
// reply
|
||||||
)
|
val decryptResult =
|
||||||
decrypted
|
MessageCrypto.decryptIncomingFull(
|
||||||
} else {
|
ciphertext = entity.content,
|
||||||
// Fallback на расшифровку plainMessage с приватным ключом
|
encryptedKey = entity.chachaKey,
|
||||||
if (privateKey != null && entity.plainMessage.isNotEmpty()) {
|
myPrivateKey = privateKey
|
||||||
try {
|
)
|
||||||
CryptoManager.decryptWithPassword(entity.plainMessage, privateKey)
|
plainKeyAndNonce = decryptResult.plainKeyAndNonce
|
||||||
?: entity.plainMessage
|
val decrypted = decryptResult.plaintext
|
||||||
} catch (e: Exception) {
|
// 🚀 Сохраняем в кэш
|
||||||
|
decryptionCache[entity.messageId] = decrypted
|
||||||
|
decrypted
|
||||||
|
} else {
|
||||||
|
// Fallback на расшифровку plainMessage с приватным ключом
|
||||||
|
if (privateKey != null && entity.plainMessage.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
val result =
|
||||||
|
CryptoManager.decryptWithPassword(
|
||||||
|
entity.plainMessage,
|
||||||
|
privateKey
|
||||||
|
)
|
||||||
|
?: entity.plainMessage
|
||||||
|
decryptionCache[entity.messageId] = result
|
||||||
|
result
|
||||||
|
} catch (e: Exception) {
|
||||||
|
entity.plainMessage
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entity.plainMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Пробуем расшифровать plainMessage
|
||||||
|
val privateKey = myPrivateKey
|
||||||
|
if (privateKey != null && entity.plainMessage.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
CryptoManager.decryptWithPassword(
|
||||||
|
entity.plainMessage,
|
||||||
|
privateKey
|
||||||
|
)
|
||||||
|
?: entity.plainMessage
|
||||||
|
} catch (e2: Exception) {
|
||||||
|
entity.plainMessage
|
||||||
|
}
|
||||||
|
} else {
|
||||||
entity.plainMessage
|
entity.plainMessage
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
entity.plainMessage
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Пробуем расшифровать plainMessage
|
|
||||||
val privateKey = myPrivateKey
|
|
||||||
if (privateKey != null && entity.plainMessage.isNotEmpty()) {
|
|
||||||
try {
|
|
||||||
CryptoManager.decryptWithPassword(entity.plainMessage, privateKey)
|
|
||||||
?: entity.plainMessage
|
|
||||||
} catch (e2: Exception) {
|
|
||||||
entity.plainMessage
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
entity.plainMessage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Парсим attachments для поиска MESSAGES (цитата)
|
// Парсим attachments для поиска MESSAGES (цитата)
|
||||||
// 🔥 ВАЖНО: Передаем content и chachaKey для расшифровки reply blob если нужно
|
// <EFBFBD> P2.3: Передаём plainKeyAndNonce чтобы избежать повторного ECDH
|
||||||
var replyData =
|
var replyData =
|
||||||
parseReplyFromAttachments(
|
parseReplyFromAttachments(
|
||||||
attachmentsJson = entity.attachments,
|
attachmentsJson = entity.attachments,
|
||||||
isFromMe = entity.fromMe == 1,
|
isFromMe = entity.fromMe == 1,
|
||||||
content = entity.content,
|
content = entity.content,
|
||||||
chachaKey = entity.chachaKey
|
chachaKey = entity.chachaKey,
|
||||||
|
plainKeyAndNonce = plainKeyAndNonce
|
||||||
)
|
)
|
||||||
|
|
||||||
// Если не нашли reply в attachments, пробуем распарсить из текста
|
// Если не нашли reply в attachments, пробуем распарсить из текста
|
||||||
@@ -1063,7 +1119,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
attachmentsJson: String,
|
attachmentsJson: String,
|
||||||
isFromMe: Boolean,
|
isFromMe: Boolean,
|
||||||
content: String,
|
content: String,
|
||||||
chachaKey: String
|
chachaKey: String,
|
||||||
|
plainKeyAndNonce: ByteArray? =
|
||||||
|
null // 🚀 P2.3: Переиспользуем ключ из основной расшифровки
|
||||||
): ReplyData? {
|
): ReplyData? {
|
||||||
|
|
||||||
if (attachmentsJson.isEmpty() || attachmentsJson == "[]") {
|
if (attachmentsJson.isEmpty() || attachmentsJson == "[]") {
|
||||||
@@ -1133,8 +1191,23 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
} catch (e: Exception) {}
|
} catch (e: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 Способ 3: Пробуем decryptReplyBlob с plainKeyAndNonce
|
// <EFBFBD> Способ 3: Используем plainKeyAndNonce из основной расшифровки (без
|
||||||
|
// повторного ECDH)
|
||||||
|
if (!decryptionSuccess && plainKeyAndNonce != null) {
|
||||||
|
try {
|
||||||
|
val decrypted =
|
||||||
|
MessageCrypto.decryptReplyBlob(dataJson, plainKeyAndNonce)
|
||||||
|
if (decrypted.isNotEmpty()) {
|
||||||
|
dataJson = decrypted
|
||||||
|
decryptionSuccess = true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 Способ 4 (fallback): Полный decryptIncomingFull если нет
|
||||||
|
// plainKeyAndNonce
|
||||||
if (!decryptionSuccess &&
|
if (!decryptionSuccess &&
|
||||||
|
plainKeyAndNonce == null &&
|
||||||
content.isNotEmpty() &&
|
content.isNotEmpty() &&
|
||||||
chachaKey.isNotEmpty() &&
|
chachaKey.isNotEmpty() &&
|
||||||
privateKey != null
|
privateKey != null
|
||||||
@@ -1146,9 +1219,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
chachaKey,
|
chachaKey,
|
||||||
privateKey
|
privateKey
|
||||||
)
|
)
|
||||||
val plainKeyAndNonce = decryptResult.plainKeyAndNonce
|
val keyAndNonce = decryptResult.plainKeyAndNonce
|
||||||
val decrypted =
|
val decrypted =
|
||||||
MessageCrypto.decryptReplyBlob(dataJson, plainKeyAndNonce)
|
MessageCrypto.decryptReplyBlob(dataJson, keyAndNonce)
|
||||||
if (decrypted.isNotEmpty()) {
|
if (decrypted.isNotEmpty()) {
|
||||||
dataJson = decrypted
|
dataJson = decrypted
|
||||||
decryptionSuccess = true
|
decryptionSuccess = true
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ fun MessageInputBar(
|
|||||||
onAttachClick: () -> Unit = {},
|
onAttachClick: () -> Unit = {},
|
||||||
myPublicKey: String = "",
|
myPublicKey: String = "",
|
||||||
opponentPublicKey: String = "",
|
opponentPublicKey: String = "",
|
||||||
myPrivateKey: String = ""
|
myPrivateKey: String = "",
|
||||||
|
inputFocusTrigger: Int = 0
|
||||||
) {
|
) {
|
||||||
val hasReply = replyMessages.isNotEmpty()
|
val hasReply = replyMessages.isNotEmpty()
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
@@ -111,6 +112,18 @@ fun MessageInputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return focus to input after closing the photo editor
|
||||||
|
LaunchedEffect(inputFocusTrigger) {
|
||||||
|
if (inputFocusTrigger > 0) {
|
||||||
|
delay(100)
|
||||||
|
editTextView?.let { editText ->
|
||||||
|
editText.requestFocus()
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val imeInsets = WindowInsets.ime
|
val imeInsets = WindowInsets.ime
|
||||||
var isKeyboardVisible by remember { mutableStateOf(false) }
|
var isKeyboardVisible by remember { mutableStateOf(false) }
|
||||||
var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) }
|
var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) }
|
||||||
|
|||||||
@@ -126,12 +126,15 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
|||||||
fun setTextWithEmojis(newText: String) {
|
fun setTextWithEmojis(newText: String) {
|
||||||
if (newText == text.toString()) return
|
if (newText == text.toString()) return
|
||||||
isUpdating = true
|
isUpdating = true
|
||||||
val cursorPos = selectionStart
|
try {
|
||||||
setText(newText)
|
val cursorPos = selectionStart
|
||||||
// Восстанавливаем позицию курсора в конец текста
|
setText(newText)
|
||||||
val newCursorPos = if (cursorPos >= 0) newText.length else cursorPos
|
// Восстанавливаем позицию курсора в конец текста
|
||||||
setSelection(newCursorPos.coerceIn(0, newText.length))
|
val newCursorPos = if (cursorPos >= 0) newText.length else cursorPos
|
||||||
isUpdating = false
|
setSelection(newCursorPos.coerceIn(0, newText.length))
|
||||||
|
} finally {
|
||||||
|
isUpdating = false
|
||||||
|
}
|
||||||
replaceEmojisWithImages(editableText)
|
replaceEmojisWithImages(editableText)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +300,10 @@ fun AppleEmojiTextField(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { view ->
|
update = { view ->
|
||||||
|
// Always update the callback to prevent stale lambda references
|
||||||
|
// after recomposition (e.g., after sending a photo)
|
||||||
|
view.onTextChange = onValueChange
|
||||||
|
|
||||||
if (view.text.toString() != value) {
|
if (view.text.toString() != value) {
|
||||||
view.setTextWithEmojis(value)
|
view.setTextWithEmojis(value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ object OptimizedEmojiCache {
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
private const val PRELOAD_COUNT = 200 // Предзагружаем первые 200 эмодзи
|
private const val PRELOAD_COUNT = 200 // Предзагружаем первые 200 эмодзи
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Предзагрузка при старте приложения
|
* Предзагрузка при старте приложения
|
||||||
|
|||||||
Reference in New Issue
Block a user