Refactor SwipeBackContainer for improved performance and readability

- Added lazy composition to skip setup until the screen is first opened, reducing allocations.
- Cleaned up code formatting for better readability.
- Enhanced comments for clarity on functionality.
- Streamlined gesture handling logic for swipe detection and animation.
This commit is contained in:
k1ngsterr1
2026-02-08 07:34:25 +05:00
parent 58b754d5ba
commit 11a8ff7644
5 changed files with 1744 additions and 1679 deletions

View File

@@ -8,15 +8,13 @@ import com.rosetta.messenger.network.*
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.MessageLogger
import java.util.UUID
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
/**
* UI модель сообщения
*/
/** UI модель сообщения */
data class Message(
val id: Long = 0,
val messageId: String,
@@ -31,9 +29,7 @@ data class Message(
val replyToMessageId: String? = null
)
/**
* UI модель диалога
*/
/** UI модель диалога */
data class Dialog(
val opponentKey: String,
val opponentTitle: String,
@@ -46,10 +42,7 @@ data class Dialog(
val verified: Boolean
)
/**
* Repository для работы с сообщениями
* Оптимизированная версия с кэшированием и Optimistic UI
*/
/** Repository для работы с сообщениями Оптимизированная версия с кэшированием и Optimistic UI */
class MessageRepository private constructor(private val context: Context) {
private val database = RosettaDatabase.getDatabase(context)
@@ -68,7 +61,8 @@ class MessageRepository private constructor(private val context: Context) {
// 🔔 События новых сообщений для обновления UI в реальном времени
// 🔥 Увеличен буфер до 64 + DROP_OLDEST для защиты от переполнения при спаме
private val _newMessageEvents = MutableSharedFlow<String>(
private val _newMessageEvents =
MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
@@ -82,12 +76,14 @@ class MessageRepository private constructor(private val context: Context) {
val status: DeliveryStatus
)
private val _deliveryStatusEvents = MutableSharedFlow<DeliveryStatusUpdate>(
private val _deliveryStatusEvents =
MutableSharedFlow<DeliveryStatusUpdate>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
)
val deliveryStatusEvents: SharedFlow<DeliveryStatusUpdate> = _deliveryStatusEvents.asSharedFlow()
val deliveryStatusEvents: SharedFlow<DeliveryStatusUpdate> =
_deliveryStatusEvents.asSharedFlow()
// 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы
private val requestedUserInfoKeys = mutableSetOf<String>()
@@ -97,12 +93,12 @@ class MessageRepository private constructor(private val context: Context) {
private var currentPrivateKey: String? = null
companion object {
@Volatile
private var INSTANCE: MessageRepository? = null
@Volatile private var INSTANCE: MessageRepository? = null
// 🔥 In-memory кэш обработанных messageId для предотвращения дубликатов
// LRU кэш с ограничением 1000 элементов - защита от race conditions
private val processedMessageIds = java.util.Collections.synchronizedSet(
private val processedMessageIds =
java.util.Collections.synchronizedSet(
object : LinkedHashSet<String>() {
override fun add(element: String): Boolean {
if (size >= 1000) remove(first())
@@ -112,26 +108,27 @@ class MessageRepository private constructor(private val context: Context) {
)
/**
* Помечает messageId как обработанный и возвращает true если это новый ID
* Возвращает false если сообщение уже было обработано (дубликат)
* Помечает messageId как обработанный и возвращает true если это новый ID Возвращает false
* если сообщение уже было обработано (дубликат)
*/
fun markAsProcessed(messageId: String): Boolean = processedMessageIds.add(messageId)
/**
* Очистка кэша (вызывается при logout)
*/
/** Очистка кэша (вызывается при logout) */
fun clearProcessedCache() = processedMessageIds.clear()
fun getInstance(context: Context): MessageRepository {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: MessageRepository(context.applicationContext).also { INSTANCE = it }
return INSTANCE
?: synchronized(this) {
INSTANCE
?: MessageRepository(context.applicationContext).also {
INSTANCE = it
}
}
}
/**
* Генерация уникального messageId
* 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного хэша,
* чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями)
* Генерация уникального messageId 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного
* хэша, чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями)
*/
fun generateMessageId(fromPublicKey: String, toPublicKey: String, timestamp: Long): String {
// Генерируем UUID для гарантии уникальности
@@ -139,9 +136,7 @@ class MessageRepository private constructor(private val context: Context) {
}
}
/**
* Инициализация с текущим аккаунтом
*/
/** Инициализация с текущим аккаунтом */
fun initialize(publicKey: String, privateKey: String) {
val start = System.currentTimeMillis()
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
@@ -151,31 +146,18 @@ class MessageRepository private constructor(private val context: Context) {
currentAccount = publicKey
currentPrivateKey = privateKey
// Загрузка диалогов
scope.launch {
dialogDao.getDialogsFlow(publicKey).collect { entities ->
_dialogs.value = entities.map { it.toDialog() }
// 🔥 Запрашиваем информацию о пользователях, у которых нет имени
entities.forEach { dialog ->
if (dialog.opponentTitle.isBlank() || dialog.opponentTitle == dialog.opponentKey.take(7)) {
requestUserInfo(dialog.opponentKey)
}
}
}
}
// 🚀 ОПТИМИЗАЦИЯ: Убрана дублирующая подписка на dialogDao.getDialogsFlow()
// Подписка на диалоги и загрузка user-info уже выполняется в
// ChatsListViewModel.setAccount()
// Дублирование вызывало двойную обработку каждого обновления таблицы dialogs
}
/**
* Проверка инициализации
*/
/** Проверка инициализации */
fun isInitialized(): Boolean {
return currentAccount != null && currentPrivateKey != null
}
/**
* Получить поток сообщений для диалога
*/
/** Получить поток сообщений для диалога */
fun getMessagesFlow(opponentKey: String): StateFlow<List<Message>> {
val dialogKey = getDialogKey(opponentKey)
@@ -192,10 +174,7 @@ class MessageRepository private constructor(private val context: Context) {
}
}
/**
* Отправка сообщения с Optimistic UI
* Возвращает сразу, шифрование и отправка в фоне
*/
/** Отправка сообщения с Optimistic UI Возвращает сразу, шифрование и отправка в фоне */
suspend fun sendMessage(
toPublicKey: String,
text: String,
@@ -224,7 +203,8 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Создаем оптимистичное сообщение
// 📁 Для saved messages - сразу DELIVERED и прочитано
val optimisticMessage = Message(
val optimisticMessage =
Message(
messageId = messageId,
fromPublicKey = account,
toPublicKey = toPublicKey,
@@ -232,7 +212,10 @@ class MessageRepository private constructor(private val context: Context) {
timestamp = timestamp,
isFromMe = true,
isRead = isSavedMessages, // 📁 Если сам себе - сразу прочитано
deliveryStatus = if (isSavedMessages) DeliveryStatus.DELIVERED else DeliveryStatus.WAITING, // 📁 Для saved messages - сразу доставлено
deliveryStatus =
if (isSavedMessages) DeliveryStatus.DELIVERED
else DeliveryStatus.WAITING, // 📁 Для saved messages - сразу
// доставлено
attachments = attachments,
replyToMessageId = replyToMessageId
)
@@ -246,10 +229,7 @@ class MessageRepository private constructor(private val context: Context) {
val startTime = System.currentTimeMillis()
try {
// Шифрование
val encryptResult = MessageCrypto.encryptForSending(
text.trim(),
toPublicKey
)
val encryptResult = MessageCrypto.encryptForSending(text.trim(), toPublicKey)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
@@ -267,14 +247,16 @@ class MessageRepository private constructor(private val context: Context) {
val attachmentsJson = serializeAttachments(attachments)
// 🔒 Шифруем plainMessage с использованием приватного ключа
val encryptedPlainMessage = CryptoManager.encryptWithPassword(text.trim(), privateKey)
val encryptedPlainMessage =
CryptoManager.encryptWithPassword(text.trim(), privateKey)
// ✅ Проверяем существование - не дублируем сообщения
val exists = messageDao.messageExists(account, messageId)
if (!exists) {
// Сохраняем в БД только если сообщения нет
// 📁 Для saved messages - сразу read=1 и delivered=DELIVERED
val entity = MessageEntity(
val entity =
MessageEntity(
account = account,
fromPublicKey = account,
toPublicKey = toPublicKey,
@@ -283,7 +265,9 @@ class MessageRepository private constructor(private val context: Context) {
chachaKey = encryptedKey,
read = if (isSavedMessages) 1 else 0,
fromMe = 1,
delivered = if (isSavedMessages) DeliveryStatus.DELIVERED.value else DeliveryStatus.WAITING.value,
delivered =
if (isSavedMessages) DeliveryStatus.DELIVERED.value
else DeliveryStatus.WAITING.value,
messageId = messageId,
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
attachments = attachmentsJson,
@@ -309,7 +293,8 @@ class MessageRepository private constructor(private val context: Context) {
unreadCount = dialog?.unreadCount ?: 0
)
// 🔥 Отмечаем что я отправлял сообщения в этот диалог (перемещает из requests в chats)
// 🔥 Отмечаем что я отправлял сообщения в этот диалог (перемещает из requests в
// chats)
val updatedRows = dialogDao.markIHaveSent(account, toPublicKey)
// 📁 НЕ отправляем пакет на сервер для saved messages!
@@ -317,11 +302,13 @@ class MessageRepository private constructor(private val context: Context) {
if (isSavedMessages) {
MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
MessageLogger.debug("📁 SavedMessages: skipping server send")
return@launch // Для saved messages - только локальное сохранение, без отправки на сервер
return@launch // Для saved messages - только локальное сохранение, без отправки
// на сервер
}
// Отправляем пакет (только для обычных диалогов)
val packet = PacketMessage().apply {
val packet =
PacketMessage().apply {
this.fromPublicKey = account
this.toPublicKey = toPublicKey
this.content = encryptedContent
@@ -339,7 +326,6 @@ class MessageRepository private constructor(private val context: Context) {
// 📝 LOG: Успешная отправка
MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
} catch (e: Exception) {
// 📝 LOG: Ошибка отправки
MessageLogger.logSendError(messageId, e)
@@ -353,17 +339,19 @@ class MessageRepository private constructor(private val context: Context) {
return optimisticMessage
}
/**
* Обработка входящего сообщения
*/
/** Обработка входящего сообщения */
suspend fun handleIncomingMessage(packet: PacketMessage) {
val startTime = System.currentTimeMillis()
val account = currentAccount ?: run {
val account =
currentAccount
?: run {
MessageLogger.debug("📥 RECEIVE SKIP: account is null")
return
}
val privateKey = currentPrivateKey ?: run {
val privateKey =
currentPrivateKey
?: run {
MessageLogger.debug("📥 RECEIVE SKIP: privateKey is null")
return
}
@@ -386,8 +374,14 @@ class MessageRepository private constructor(private val context: Context) {
}
// 🔥 Генерируем messageId если он пустой (как в Архиве - generateRandomKeyFormSeed)
val messageId = if (packet.messageId.isBlank()) {
val generatedId = generateMessageId(packet.fromPublicKey, packet.toPublicKey, packet.timestamp)
val messageId =
if (packet.messageId.isBlank()) {
val generatedId =
generateMessageId(
packet.fromPublicKey,
packet.toPublicKey,
packet.timestamp
)
MessageLogger.debug("📥 Generated messageId: $generatedId (original was blank)")
generatedId
} else {
@@ -397,7 +391,9 @@ class MessageRepository private constructor(private val context: Context) {
// 🔥 ПЕРВЫЙ УРОВЕНЬ ЗАЩИТЫ: In-memory кэш (быстрая проверка без обращения к БД)
// markAsProcessed возвращает false если сообщение уже обрабатывалось
if (!markAsProcessed(messageId)) {
MessageLogger.debug("📥 SKIP (in-memory cache): Message $messageId already being processed")
MessageLogger.debug(
"📥 SKIP (in-memory cache): Message $messageId already being processed"
)
return
}
@@ -413,14 +409,12 @@ class MessageRepository private constructor(private val context: Context) {
try {
// 🔑 КРИТИЧНО: Сохраняем ЗАШИФРОВАННЫЙ chacha_key (как в Desktop!)
// Desktop: хранит зашифрованный ключ, расшифровывает только при использовании
// Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8')
// Buffer.from(await decrypt(message.chacha_key, privatePlain),
// "binary").toString('utf-8')
// Расшифровываем
val plainText = MessageCrypto.decryptIncoming(
packet.content,
packet.chachaKey,
privateKey
)
val plainText =
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
// 📝 LOG: Расшифровка успешна
MessageLogger.logDecryptionSuccess(
@@ -430,7 +424,8 @@ class MessageRepository private constructor(private val context: Context) {
)
// Сериализуем attachments в JSON с расшифровкой MESSAGES blob
val attachmentsJson = serializeAttachmentsWithDecryption(
val attachmentsJson =
serializeAttachmentsWithDecryption(
packet.attachments,
packet.chachaKey,
privateKey
@@ -440,13 +435,19 @@ class MessageRepository private constructor(private val context: Context) {
processImageAttachments(packet.attachments, packet.chachaKey, privateKey)
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
processAvatarAttachments(packet.attachments, packet.fromPublicKey, packet.chachaKey, privateKey)
processAvatarAttachments(
packet.attachments,
packet.fromPublicKey,
packet.chachaKey,
privateKey
)
// 🔒 Шифруем plainMessage с использованием приватного ключа
val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey)
// Создаем entity для кэша и возможной вставки
val entity = MessageEntity(
val entity =
MessageEntity(
account = account,
fromPublicKey = packet.fromPublicKey,
toPublicKey = packet.toPublicKey,
@@ -499,7 +500,6 @@ class MessageRepository private constructor(private val context: Context) {
// 📝 LOG: Успешная обработка
MessageLogger.logReceiveSuccess(messageId, System.currentTimeMillis() - startTime)
} catch (e: Exception) {
// 📝 LOG: Ошибка обработки
MessageLogger.logDecryptionError(messageId, e)
@@ -507,9 +507,7 @@ class MessageRepository private constructor(private val context: Context) {
}
}
/**
* Обработка подтверждения доставки
*/
/** Обработка подтверждения доставки */
suspend fun handleDelivery(packet: PacketDelivery) {
val account = currentAccount ?: return
@@ -536,8 +534,7 @@ class MessageRepository private constructor(private val context: Context) {
}
/**
* Обработка прочтения
* В Desktop PacketRead сообщает что собеседник прочитал наши сообщения
* Обработка прочтения В Desktop PacketRead сообщает что собеседник прочитал наши сообщения
* fromPublicKey - кто прочитал (собеседник)
*/
suspend fun handleRead(packet: PacketRead) {
@@ -558,22 +555,17 @@ class MessageRepository private constructor(private val context: Context) {
val dialogKey = getDialogKey(packet.fromPublicKey)
val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0
messageCache[dialogKey]?.let { flow ->
flow.value = flow.value.map { msg ->
if (msg.isFromMe && !msg.isRead) msg.copy(isRead = true)
else msg
flow.value =
flow.value.map { msg ->
if (msg.isFromMe && !msg.isRead) msg.copy(isRead = true) else msg
}
}
// 🔔 Уведомляем UI о прочтении (пустой messageId = все исходящие сообщения)
_deliveryStatusEvents.tryEmit(
DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ)
)
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
// 📝 LOG: Статус прочтения
MessageLogger.logReadStatus(
fromPublicKey = packet.fromPublicKey,
messagesCount = readCount
)
MessageLogger.logReadStatus(fromPublicKey = packet.fromPublicKey, messagesCount = readCount)
// 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageRead обновился
dialogDao.updateDialogFromMessages(account, packet.fromPublicKey)
@@ -583,9 +575,8 @@ class MessageRepository private constructor(private val context: Context) {
}
/**
* Отметить диалог как прочитанный
* 🔥 После обновления messages обновляем диалог через updateDialogFromMessages
* 📁 SAVED MESSAGES: Использует специальный метод для saved messages
* Отметить диалог как прочитанный 🔥 После обновления messages обновляем диалог через
* updateDialogFromMessages 📁 SAVED MESSAGES: Использует специальный метод для saved messages
*/
suspend fun markDialogAsRead(opponentKey: String) {
val account = currentAccount ?: return
@@ -602,13 +593,9 @@ class MessageRepository private constructor(private val context: Context) {
} else {
dialogDao.updateDialogFromMessages(account, opponentKey)
}
}
/**
* Отправить уведомление "печатает"
* 📁 Для Saved Messages - не отправляем
*/
/** Отправить уведомление "печатает" 📁 Для Saved Messages - не отправляем */
fun sendTyping(toPublicKey: String) {
val account = currentAccount ?: return
val privateKey = currentPrivateKey ?: return
@@ -619,7 +606,8 @@ class MessageRepository private constructor(private val context: Context) {
}
scope.launch {
val packet = PacketTyping().apply {
val packet =
PacketTyping().apply {
this.fromPublicKey = account
this.toPublicKey = toPublicKey
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -628,9 +616,7 @@ class MessageRepository private constructor(private val context: Context) {
}
}
/**
* Создать или обновить диалог
*/
/** Создать или обновить диалог */
suspend fun createOrUpdateDialog(
opponentKey: String,
title: String = "",
@@ -641,15 +627,23 @@ class MessageRepository private constructor(private val context: Context) {
val existing = dialogDao.getDialog(account, opponentKey)
if (existing != null) {
dialogDao.updateOpponentInfo(account, opponentKey, title, username, if (verified) 1 else 0)
dialogDao.updateOpponentInfo(
account,
opponentKey,
title,
username,
if (verified) 1 else 0
)
} else {
dialogDao.insertDialog(DialogEntity(
dialogDao.insertDialog(
DialogEntity(
account = account,
opponentKey = opponentKey,
opponentTitle = title,
opponentUsername = username,
verified = if (verified) 1 else 0
))
)
)
}
}
@@ -658,8 +652,8 @@ class MessageRepository private constructor(private val context: Context) {
// ===============================
/**
* Получить ключ диалога для группировки сообщений
* 📁 SAVED MESSAGES: Для saved messages (account == opponentKey) возвращает просто account
* Получить ключ диалога для группировки сообщений 📁 SAVED MESSAGES: Для saved messages
* (account == opponentKey) возвращает просто account
*/
private fun getDialogKey(opponentKey: String): String {
val account = currentAccount ?: return opponentKey
@@ -668,8 +662,7 @@ class MessageRepository private constructor(private val context: Context) {
return account
}
// Для обычных диалогов - сортируем ключи
return if (account < opponentKey) "$account:$opponentKey"
else "$opponentKey:$account"
return if (account < opponentKey) "$account:$opponentKey" else "$opponentKey:$account"
}
private fun updateMessageCache(dialogKey: String, message: Message) {
@@ -688,9 +681,9 @@ class MessageRepository private constructor(private val context: Context) {
private fun updateMessageStatus(dialogKey: String, messageId: String, status: DeliveryStatus) {
messageCache[dialogKey]?.let { flow ->
flow.value = flow.value.map { msg ->
if (msg.messageId == messageId) msg.copy(deliveryStatus = status)
else msg
flow.value =
flow.value.map { msg ->
if (msg.messageId == messageId) msg.copy(deliveryStatus = status) else msg
}
}
}
@@ -720,23 +713,22 @@ class MessageRepository private constructor(private val context: Context) {
dialogDao.updateUnreadCount(account, opponentKey, unreadCount)
} else {
// Создаем новый диалог
dialogDao.insertDialog(DialogEntity(
dialogDao.insertDialog(
DialogEntity(
account = account,
opponentKey = opponentKey,
lastMessage = encryptedLastMessage,
lastMessageTimestamp = timestamp,
unreadCount = unreadCount
))
)
)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* Обновить онлайн-статус пользователя в диалоге
*/
/** Обновить онлайн-статус пользователя в диалоге */
suspend fun updateOnlineStatus(publicKey: String, isOnline: Boolean) {
val account = currentAccount ?: return
@@ -747,17 +739,13 @@ class MessageRepository private constructor(private val context: Context) {
isOnline = if (isOnline) 1 else 0,
lastSeen = if (!isOnline) System.currentTimeMillis() else 0
)
}
/**
* Наблюдать за онлайн статусом пользователя
*/
/** Наблюдать за онлайн статусом пользователя */
fun observeUserOnlineStatus(publicKey: String): Flow<Pair<Boolean, Long>> {
val account = currentAccount ?: return flowOf(false to 0L)
return dialogDao.observeOnlineStatus(account, publicKey)
.map { info ->
return dialogDao.observeOnlineStatus(account, publicKey).map { info ->
if (info != null) {
(info.isOnline == 1) to info.lastSeen
} else {
@@ -767,13 +755,17 @@ class MessageRepository private constructor(private val context: Context) {
}
/**
* Обновить информацию о пользователе в диалоге (имя, username, verified)
* Вызывается когда приходит ответ на PacketSearch
* Обновить информацию о пользователе в диалоге (имя, username, verified) Вызывается когда
* приходит ответ на PacketSearch
*/
suspend fun updateDialogUserInfo(publicKey: String, title: String, username: String, verified: Int) {
suspend fun updateDialogUserInfo(
publicKey: String,
title: String,
username: String,
verified: Int
) {
val account = currentAccount ?: return
// Проверяем существует ли диалог с этим пользователем
val existing = dialogDao.getDialog(account, publicKey)
if (existing != null) {
@@ -781,14 +773,12 @@ class MessageRepository private constructor(private val context: Context) {
// 🔥 Проверим что данные сохранились
val updated = dialogDao.getDialog(account, publicKey)
} else {
}
} else {}
}
/**
* Очистить кэш сообщений для конкретного диалога
* 🔥 ВАЖНО: Устанавливаем пустой список, а не просто удаляем -
* чтобы подписчики Flow увидели что диалог пуст
* Очистить кэш сообщений для конкретного диалога 🔥 ВАЖНО: Устанавливаем пустой список, а не
* просто удаляем - чтобы подписчики Flow увидели что диалог пуст
*/
fun clearDialogCache(opponentKey: String) {
val dialogKey = getDialogKey(opponentKey)
@@ -801,8 +791,8 @@ class MessageRepository private constructor(private val context: Context) {
}
/**
* Запросить информацию о пользователе с сервера
* 🔥 Защита от бесконечных запросов - каждый ключ запрашивается только один раз
* Запросить информацию о пользователе с сервера 🔥 Защита от бесконечных запросов - каждый ключ
* запрашивается только один раз
*/
fun requestUserInfo(publicKey: String) {
val privateKey = currentPrivateKey ?: return
@@ -815,7 +805,8 @@ class MessageRepository private constructor(private val context: Context) {
scope.launch {
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val packet = PacketSearch().apply {
val packet =
PacketSearch().apply {
this.privateKey = privateKeyHash
this.search = publicKey
}
@@ -827,7 +818,8 @@ class MessageRepository private constructor(private val context: Context) {
private fun MessageEntity.toMessage(): Message {
// 🔓 Расшифровываем plainMessage с использованием приватного ключа
val privateKey = currentPrivateKey
val decryptedText = if (privateKey != null && plainMessage.isNotEmpty()) {
val decryptedText =
if (privateKey != null && plainMessage.isNotEmpty()) {
try {
CryptoManager.decryptWithPassword(plainMessage, privateKey) ?: plainMessage
} catch (e: Exception) {
@@ -851,7 +843,8 @@ class MessageRepository private constructor(private val context: Context) {
)
}
private fun DialogEntity.toDialog() = Dialog(
private fun DialogEntity.toDialog() =
Dialog(
opponentKey = opponentKey,
opponentTitle = opponentTitle,
opponentUsername = opponentUsername,
@@ -864,17 +857,16 @@ class MessageRepository private constructor(private val context: Context) {
)
/**
* Сериализация attachments в JSON
* 🔥 ВАЖНО: blob НЕ сохраняется в БД (как в desktop)
* Только метаданные: id, type, preview, width, height
* blob скачивается с CDN по id при показе
* Сериализация attachments в JSON 🔥 ВАЖНО: blob НЕ сохраняется в БД (как в desktop) Только
* метаданные: id, type, preview, width, height blob скачивается с CDN по id при показе
*/
private fun serializeAttachments(attachments: List<MessageAttachment>): String {
if (attachments.isEmpty()) return "[]"
val jsonArray = JSONArray()
for (attachment in attachments) {
val jsonObj = JSONObject().apply {
val jsonObj =
JSONObject().apply {
put("id", attachment.id)
// 🔥 blob НЕ сохраняем в БД - скачивается с CDN по id
put("blob", "")
@@ -889,8 +881,8 @@ class MessageRepository private constructor(private val context: Context) {
}
/**
* 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш
* Как в desktop: при получении attachment с типом AVATAR - сохраняем в avatar_cache
* 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш Как в desktop: при
* получении attachment с типом AVATAR - сохраняем в avatar_cache
*/
private suspend fun processAvatarAttachments(
attachments: List<MessageAttachment>,
@@ -905,18 +897,20 @@ class MessageRepository private constructor(private val context: Context) {
try {
// 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob = MessageCrypto.decryptAttachmentBlob(
val decryptedBlob =
MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
privateKey
)
if (decryptedBlob != null) {
// 2. Сохраняем аватар в кэш
val filePath = AvatarFileManager.saveAvatar(context, decryptedBlob, fromPublicKey)
val filePath =
AvatarFileManager.saveAvatar(context, decryptedBlob, fromPublicKey)
val entity = AvatarCacheEntity(
val entity =
AvatarCacheEntity(
publicKey = fromPublicKey,
avatar = filePath,
timestamp = System.currentTimeMillis()
@@ -925,19 +919,16 @@ class MessageRepository private constructor(private val context: Context) {
// 3. Очищаем старые аватары (оставляем последние 5)
avatarDao.deleteOldAvatars(fromPublicKey, 5)
} else {
}
} catch (e: Exception) {
}
} else {}
} catch (e: Exception) {}
}
}
}
/**
* 🖼️ Обработка IMAGE attachments - сохранение в файл (как в desktop)
* Desktop сохраняет: writeFile(`m/${md5(attachment.id + publicKey)}`, encryptedBlob)
* Файлы (FILE тип) НЕ сохраняются - они слишком большие, загружаются с CDN
* 🖼️ Обработка IMAGE attachments - сохранение в файл (как в desktop) Desktop сохраняет:
* writeFile(`m/${md5(attachment.id + publicKey)}`, encryptedBlob) Файлы (FILE тип) НЕ
* сохраняются - они слишком большие, загружаются с CDN
*/
private fun processImageAttachments(
attachments: List<MessageAttachment>,
@@ -952,7 +943,8 @@ class MessageRepository private constructor(private val context: Context) {
try {
// 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob = MessageCrypto.decryptAttachmentBlob(
val decryptedBlob =
MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
privateKey
@@ -960,7 +952,8 @@ class MessageRepository private constructor(private val context: Context) {
if (decryptedBlob != null) {
// 2. Сохраняем в файл (как в desktop)
val saved = AttachmentFileManager.saveAttachment(
val saved =
AttachmentFileManager.saveAttachment(
context = context,
blob = decryptedBlob,
attachmentId = attachment.id,
@@ -968,20 +961,15 @@ class MessageRepository private constructor(private val context: Context) {
privateKey = privateKey
)
if (saved) {
} else {
}
} else {
}
} catch (e: Exception) {
}
if (saved) {} else {}
} else {}
} catch (e: Exception) {}
}
}
}
/**
* Сериализация attachments в JSON с RE-ENCRYPTION для хранения в БД
* Для MESSAGES типа:
* Сериализация attachments в JSON с RE-ENCRYPTION для хранения в БД Для MESSAGES типа:
* 1. Расшифровываем blob с ChaCha ключом сообщения
* 2. Re-encrypt с приватным ключом (как в Desktop Архиве)
* 3. Сохраняем зашифрованный blob в БД
@@ -1002,7 +990,8 @@ class MessageRepository private constructor(private val context: Context) {
if (attachment.type == AttachmentType.MESSAGES && attachment.blob.isNotEmpty()) {
try {
// 1. Расшифровываем с ChaCha ключом сообщения
val decryptedBlob = MessageCrypto.decryptAttachmentBlob(
val decryptedBlob =
MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
privateKey
@@ -1010,9 +999,11 @@ class MessageRepository private constructor(private val context: Context) {
if (decryptedBlob != null) {
// 2. Re-encrypt с приватным ключом для хранения (как в Desktop Архиве)
val reEncryptedBlob = CryptoManager.encryptWithPassword(decryptedBlob, privateKey)
val reEncryptedBlob =
CryptoManager.encryptWithPassword(decryptedBlob, privateKey)
// 3. Сохраняем ЗАШИФРОВАННЫЙ blob в БД (только для MESSAGES - они небольшие)
// 3. Сохраняем ЗАШИФРОВАННЫЙ blob в БД (только для MESSAGES - они
// небольшие)
jsonObj.put("id", attachment.id)
jsonObj.put("blob", reEncryptedBlob) // 🔒 Зашифрован приватным ключом!
jsonObj.put("type", attachment.type.value)

View File

@@ -8,14 +8,14 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [
entities =
[
EncryptedAccountEntity::class,
MessageEntity::class,
DialogEntity::class,
BlacklistEntity::class,
AvatarCacheEntity::class
],
version = 10,
AvatarCacheEntity::class],
version = 11,
exportSchema = false
)
abstract class RosettaDatabase : RoomDatabase() {
@@ -26,41 +26,54 @@ abstract class RosettaDatabase : RoomDatabase() {
abstract fun avatarDao(): AvatarDao
companion object {
@Volatile
private var INSTANCE: RosettaDatabase? = null
@Volatile private var INSTANCE: RosettaDatabase? = null
private val MIGRATION_4_5 = object : Migration(4, 5) {
private val MIGRATION_4_5 =
object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
// Добавляем новые столбцы для индикаторов прочтения
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_from_me INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_delivered INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0")
database.execSQL(
"ALTER TABLE dialogs ADD COLUMN last_message_from_me INTEGER NOT NULL DEFAULT 0"
)
database.execSQL(
"ALTER TABLE dialogs ADD COLUMN last_message_delivered INTEGER NOT NULL DEFAULT 0"
)
database.execSQL(
"ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0"
)
}
}
private val MIGRATION_5_6 = object : Migration(5, 6) {
private val MIGRATION_5_6 =
object : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
// Добавляем поле username в encrypted_accounts
database.execSQL("ALTER TABLE encrypted_accounts ADD COLUMN username TEXT")
}
}
private val MIGRATION_6_7 = object : Migration(6, 7) {
private val MIGRATION_6_7 =
object : Migration(6, 7) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаем таблицу для кэша аватаров
database.execSQL("""
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS avatar_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_key TEXT NOT NULL,
avatar TEXT NOT NULL,
timestamp INTEGER NOT NULL
)
""")
database.execSQL("CREATE INDEX IF NOT EXISTS index_avatar_cache_public_key_timestamp ON avatar_cache (public_key, timestamp)")
"""
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS index_avatar_cache_public_key_timestamp ON avatar_cache (public_key, timestamp)"
)
}
}
private val MIGRATION_7_8 = object : Migration(7, 8) {
private val MIGRATION_7_8 =
object : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
// Удаляем таблицу avatar_delivery (больше не нужна)
database.execSQL("DROP TABLE IF EXISTS avatar_delivery")
@@ -68,47 +81,81 @@ abstract class RosettaDatabase : RoomDatabase() {
}
/**
* 🔥 МИГРАЦИЯ 8->9: Очищаем blob из attachments (как в desktop)
* Blob слишком большой для SQLite CursorWindow (2MB лимит)
* Просто обнуляем attachments - изображения перескачаются с CDN
* 🔥 МИГРАЦИЯ 8->9: Очищаем blob из attachments (как в desktop) Blob слишком большой для
* SQLite CursorWindow (2MB лимит) Просто обнуляем attachments - изображения перескачаются с
* CDN
*/
private val MIGRATION_8_9 = object : Migration(8, 9) {
private val MIGRATION_8_9 =
object : Migration(8, 9) {
override fun migrate(database: SupportSQLiteDatabase) {
// Очищаем все attachments с большими blob'ами
// Они будут перескачаны с CDN при открытии
database.execSQL("""
database.execSQL(
"""
UPDATE messages
SET attachments = '[]'
WHERE length(attachments) > 10000
""")
"""
)
}
}
/**
* 🔥 МИГРАЦИЯ 9->10: Повторная очистка blob из attachments
* Для пользователей которые уже были на версии 9
* 🔥 МИГРАЦИЯ 9->10: Повторная очистка blob из attachments Для пользователей которые уже
* были на версии 9
*/
private val MIGRATION_9_10 = object : Migration(9, 10) {
private val MIGRATION_9_10 =
object : Migration(9, 10) {
override fun migrate(database: SupportSQLiteDatabase) {
// Очищаем все attachments с большими blob'ами
database.execSQL("""
database.execSQL(
"""
UPDATE messages
SET attachments = '[]'
WHERE length(attachments) > 10000
""")
"""
)
}
}
/**
* 🚀 МИГРАЦИЯ 10->11: Денормализация — кэш attachments последнего сообщения в dialogs
* Устраняет N+1 проблему: ранее для каждого диалога делался отдельный запрос к messages
*/
private val MIGRATION_10_11 =
object : Migration(10, 11) {
override fun migrate(database: SupportSQLiteDatabase) {
// Добавляем столбец для кэша attachments последнего сообщения
database.execSQL(
"ALTER TABLE dialogs ADD COLUMN last_message_attachments TEXT NOT NULL DEFAULT '[]'"
)
}
}
fun getDatabase(context: Context): RosettaDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
return INSTANCE
?: synchronized(this) {
val instance =
Room.databaseBuilder(
context.applicationContext,
RosettaDatabase::class.java,
"rosetta_secure.db"
)
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance
.addMigrations(MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10)
.fallbackToDestructiveMigration() // Для разработки - только если миграция не найдена
.setJournalMode(
JournalMode.WRITE_AHEAD_LOGGING
) // WAL mode for performance
.addMigrations(
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11
)
.fallbackToDestructiveMigration() // Для разработки - только
// если миграция не
// найдена
.build()
INSTANCE = instance
instance

View File

@@ -1,28 +1,26 @@
package com.rosetta.messenger.ui.chats
import android.app.Application
import androidx.compose.runtime.Immutable
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.database.DialogEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.database.BlacklistEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.PacketOnlineSubscribe
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.FlowPreview
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import androidx.compose.runtime.Immutable
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* UI модель диалога с расшифрованным lastMessage
*/
/** UI модель диалога с расшифрованным lastMessage */
@Immutable
data class DialogUiModel(
val id: Long,
@@ -40,12 +38,13 @@ data class DialogUiModel(
val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR)
val lastMessageRead: Int = 0, // Прочитано (0/1)
val lastMessageAttachmentType: String? = null // 📎 Тип attachment: "Photo", "File", или null
val lastMessageAttachmentType: String? =
null // 📎 Тип attachment: "Photo", "File", или null
)
/**
* 🔥 Комбинированное состояние чатов для атомарного обновления UI
* Это предотвращает "дергание" когда dialogs и requests обновляются независимо
* 🔥 Комбинированное состояние чатов для атомарного обновления UI Это предотвращает "дергание"
* когда dialogs и requests обновляются независимо
*/
@Immutable
data class ChatsUiState(
@@ -53,19 +52,17 @@ data class ChatsUiState(
val requests: List<DialogUiModel> = emptyList(),
val requestsCount: Int = 0
) {
val isEmpty: Boolean get() = dialogs.isEmpty() && requestsCount == 0
val hasContent: Boolean get() = dialogs.isNotEmpty() || requestsCount > 0
val isEmpty: Boolean
get() = dialogs.isEmpty() && requestsCount == 0
val hasContent: Boolean
get() = dialogs.isNotEmpty() || requestsCount > 0
}
/**
* ViewModel для списка чатов
* Загружает диалоги из базы данных и расшифровывает lastMessage
*/
/** ViewModel для списка чатов Загружает диалоги из базы данных и расшифровывает lastMessage */
class ChatsListViewModel(application: Application) : AndroidViewModel(application) {
private val database = RosettaDatabase.getDatabase(application)
private val dialogDao = database.dialogDao()
private val messageDao = database.messageDao() // 🔥 Добавляем для получения статуса последнего сообщения
private var currentAccount: String = ""
private var currentPrivateKey: String? = null
@@ -94,17 +91,16 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 НОВОЕ: Комбинированное состояние - обновляется атомарно!
// 🎯 ИСПРАВЛЕНИЕ: debounce предотвращает мерцание при быстрых обновлениях
val chatsState: StateFlow<ChatsUiState> = combine(
_dialogs,
_requests,
_requestsCount
) { dialogs, requests, count ->
val chatsState: StateFlow<ChatsUiState> =
combine(_dialogs, _requests, _requestsCount) { dialogs, requests, count ->
ChatsUiState(dialogs, requests, count)
}
.distinctUntilChanged() // 🔥 Игнорируем дублирующиеся состояния
.stateIn(
viewModelScope,
SharingStarted.Eagerly, // 🔥 КРИТИЧНО: Eagerly вместо WhileSubscribed - сразу начинаем следить
SharingStarted
.Eagerly, // 🔥 КРИТИЧНО: Eagerly вместо WhileSubscribed - сразу
// начинаем следить
ChatsUiState()
)
@@ -114,9 +110,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
private val TAG = "ChatsListVM"
/**
* Установить текущий аккаунт и загрузить диалоги
*/
/** Установить текущий аккаунт и загрузить диалоги */
fun setAccount(publicKey: String, privateKey: String) {
val setAccountStart = System.currentTimeMillis()
if (currentAccount == publicKey) {
@@ -129,61 +123,104 @@ if (currentAccount == publicKey) {
currentAccount = publicKey
currentPrivateKey = privateKey
// Подписываемся на обычные диалоги
@OptIn(FlowPreview::class)
viewModelScope.launch {
dialogDao.getDialogsFlow(publicKey)
dialogDao
.getDialogsFlow(publicKey)
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
.map { dialogsList ->
val mapStart = System.currentTimeMillis()
// <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений
withContext(Dispatchers.Default) {
dialogsList.map { dialog ->
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))) {
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)
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 на зашифрованный текст
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
// <20> ОПТИМИЗАЦИЯ: Используем денормализованные поля из
// DialogEntity
// Статус и attachments уже записаны в dialogs через
// updateDialogFromMessages()
// Это устраняет N+1 проблему (ранее: 2 запроса на
// каждый диалог)
// 📎 Определяем тип attachment последнего сообщения
// 🔥 Reply vs Forward: если есть текст - это Reply (показываем текст),
// если текст пустой - это Forward (показываем "Forwarded message")
val attachmentType = try {
val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey)
if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") {
val attachments = org.json.JSONArray(attachmentsJson)
// 📎 Определяем тип attachment из кэшированного поля в
// DialogEntity
val attachmentType =
try {
val attachmentsJson =
dialog.lastMessageAttachments
if (attachmentsJson.isNotEmpty() &&
attachmentsJson != "[]"
) {
val attachments =
org.json.JSONArray(
attachmentsJson
)
if (attachments.length() > 0) {
val firstAttachment = attachments.getJSONObject(0)
val type = firstAttachment.optInt("type", -1)
val firstAttachment =
attachments.getJSONObject(0)
val type =
firstAttachment.optInt(
"type",
-1
)
when (type) {
0 -> "Photo" // AttachmentType.IMAGE = 0
0 ->
"Photo" // AttachmentType.IMAGE = 0
1 -> {
// AttachmentType.MESSAGES = 1 (Reply или Forward)
// Reply: есть текст сообщения -> показываем текст (null)
// Forward: текст пустой -> показываем "Forwarded"
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
// AttachmentType.MESSAGES =
// 1 (Reply или Forward)
// Reply: есть текст
// сообщения -> показываем
// текст (null)
// Forward: текст пустой ->
// показываем "Forwarded"
if (decryptedLastMessage
.isNotEmpty()
)
null
else "Forwarded"
}
2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3
2 ->
"File" // AttachmentType.FILE = 2
3 ->
"Avatar" // AttachmentType.AVATAR = 3
else -> null
}
} else null
@@ -199,20 +236,32 @@ if (currentAccount == publicKey) {
opponentTitle = dialog.opponentTitle,
opponentUsername = dialog.opponentUsername,
lastMessage = decryptedLastMessage,
lastMessageTimestamp = dialog.lastMessageTimestamp,
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
isSavedMessages =
isSavedMessages, // 📁 Saved Messages
lastMessageFromMe =
dialog.lastMessageFromMe, // 🚀 Из
// DialogEntity (денормализовано)
lastMessageDelivered =
dialog.lastMessageDelivered, // 🚀 Из
// DialogEntity (денормализовано)
lastMessageRead =
dialog.lastMessageRead, // 🚀 Из
// DialogEntity
// (денормализовано)
lastMessageAttachmentType =
attachmentType // 📎 Тип attachment
)
}
}.awaitAll()
}.also {
}
.awaitAll()
}
.also {
val mapTime = System.currentTimeMillis() - mapStart
}
}
@@ -221,34 +270,52 @@ if (currentAccount == publicKey) {
_dialogs.value = decryptedDialogs
// 🟢 Подписываемся на онлайн-статусы всех собеседников
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный статус
val opponentsToSubscribe = decryptedDialogs
.filter { !it.isSavedMessages }
.map { it.opponentKey }
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный
// статус
val opponentsToSubscribe =
decryptedDialogs.filter { !it.isSavedMessages }.map {
it.opponentKey
}
subscribeToOnlineStatuses(opponentsToSubscribe, privateKey)
}
}
// 📬 Подписываемся на requests (запросы от новых пользователей)
@OptIn(FlowPreview::class)
viewModelScope.launch {
dialogDao.getRequestsFlow(publicKey)
dialogDao
.getRequestsFlow(publicKey)
.flowOn(Dispatchers.IO)
.debounce(100) // 🚀 Батчим быстрые обновления
.map { requestsList ->
// 🚀 ОПТИМИЗАЦИЯ: Параллельная расшифровка
withContext(Dispatchers.Default) {
requestsList.map { dialog ->
requestsList
.map { dialog ->
async {
// 🔥 Загружаем информацию о пользователе если её нет
// 📁 НЕ загружаем для Saved Messages
val isSavedMessages = (dialog.account == dialog.opponentKey)
if (!isSavedMessages && (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey)) {
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)
val decryptedLastMessage =
try {
if (privateKey.isNotEmpty() &&
dialog.lastMessage
.isNotEmpty()
) {
CryptoManager.decryptWithPassword(
dialog.lastMessage,
privateKey
)
?: dialog.lastMessage
} else {
dialog.lastMessage
@@ -257,26 +324,48 @@ if (currentAccount == publicKey) {
dialog.lastMessage
}
// 📎 Определяем тип attachment последнего сообщения
// 🔥 Reply vs Forward: если есть текст - это Reply (показываем текст),
// если текст пустой - это Forward (показываем "Forwarded message")
val attachmentType = try {
val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey)
if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") {
val attachments = org.json.JSONArray(attachmentsJson)
// 📎 Определяем тип attachment из кэшированного поля в
// DialogEntity
val attachmentType =
try {
val attachmentsJson =
dialog.lastMessageAttachments
if (attachmentsJson.isNotEmpty() &&
attachmentsJson != "[]"
) {
val attachments =
org.json.JSONArray(
attachmentsJson
)
if (attachments.length() > 0) {
val firstAttachment = attachments.getJSONObject(0)
val type = firstAttachment.optInt("type", -1)
val firstAttachment =
attachments.getJSONObject(0)
val type =
firstAttachment.optInt(
"type",
-1
)
when (type) {
0 -> "Photo" // AttachmentType.IMAGE = 0
0 ->
"Photo" // AttachmentType.IMAGE = 0
1 -> {
// AttachmentType.MESSAGES = 1 (Reply или Forward)
// Reply: есть текст сообщения -> показываем текст (null)
// Forward: текст пустой -> показываем "Forwarded"
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
// AttachmentType.MESSAGES =
// 1 (Reply или Forward)
// Reply: есть текст
// сообщения -> показываем
// текст (null)
// Forward: текст пустой ->
// показываем "Forwarded"
if (decryptedLastMessage
.isNotEmpty()
)
null
else "Forwarded"
}
2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3
2 ->
"File" // AttachmentType.FILE = 2
3 ->
"Avatar" // AttachmentType.AVATAR = 3
else -> null
}
} else null
@@ -289,55 +378,62 @@ if (currentAccount == publicKey) {
id = dialog.id,
account = dialog.account,
opponentKey = dialog.opponentKey,
opponentTitle = dialog.opponentTitle, // 🔥 Показываем имя как в обычных чатах
opponentTitle =
dialog.opponentTitle, // 🔥 Показываем
// имя как в
// обычных чатах
opponentUsername = dialog.opponentUsername,
lastMessage = decryptedLastMessage,
lastMessageTimestamp = dialog.lastMessageTimestamp,
lastMessageTimestamp =
dialog.lastMessageTimestamp,
unreadCount = dialog.unreadCount,
isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen,
verified = dialog.verified,
isSavedMessages = (dialog.account == dialog.opponentKey), // 📁 Saved Messages
isSavedMessages =
(dialog.account ==
dialog.opponentKey), // 📁 Saved
// Messages
lastMessageFromMe = dialog.lastMessageFromMe,
lastMessageDelivered = dialog.lastMessageDelivered,
lastMessageDelivered =
dialog.lastMessageDelivered,
lastMessageRead = dialog.lastMessageRead,
lastMessageAttachmentType = attachmentType // 📎 Тип attachment
lastMessageAttachmentType =
attachmentType // 📎 Тип attachment
)
}
}.awaitAll()
}
.awaitAll()
}
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedRequests ->
_requests.value = decryptedRequests
}
.collect { decryptedRequests -> _requests.value = decryptedRequests }
}
// 📊 Подписываемся на количество requests
viewModelScope.launch {
dialogDao.getRequestsCountFlow(publicKey)
dialogDao
.getRequestsCountFlow(publicKey)
.flowOn(Dispatchers.IO)
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
.collect { count ->
_requestsCount.value = count
}
.collect { count -> _requestsCount.value = count }
}
// 🔥 Подписываемся на блоклист — Room автоматически переэмитит при blockUser()/unblockUser()
// 🔥 Подписываемся на блоклист — Room автоматически переэмитит при
// blockUser()/unblockUser()
viewModelScope.launch {
database.blacklistDao().getBlockedUsers(publicKey)
database.blacklistDao()
.getBlockedUsers(publicKey)
.flowOn(Dispatchers.IO)
.map { entities -> entities.map { it.publicKey }.toSet() }
.distinctUntilChanged()
.collect { blockedSet ->
_blockedUsers.value = blockedSet
}
.collect { blockedSet -> _blockedUsers.value = blockedSet }
}
}
/**
* 🟢 Подписаться на онлайн-статусы всех собеседников
* 🔥 Фильтруем уже подписанные ключи чтобы избежать бесконечного цикла
* 🟢 Подписаться на онлайн-статусы всех собеседников 🔥 Фильтруем уже подписанные ключи чтобы
* избежать бесконечного цикла
*/
private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) {
if (opponentKeys.isEmpty()) return
@@ -353,23 +449,21 @@ if (currentAccount == publicKey) {
try {
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val packet = PacketOnlineSubscribe().apply {
val packet =
PacketOnlineSubscribe().apply {
this.privateKey = privateKeyHash
newKeys.forEach { key ->
addPublicKey(key)
}
newKeys.forEach { key -> addPublicKey(key) }
}
ProtocolManager.send(packet)
} catch (e: Exception) {
}
} catch (e: Exception) {}
}
}
/**
* Создать или обновить диалог после отправки/получения сообщения
* 🔥 Используем updateDialogFromMessages для пересчета счетчиков из messages
* 📁 SAVED MESSAGES: Использует специальный метод для saved messages
* Создать или обновить диалог после отправки/получения сообщения 🔥 Используем
* updateDialogFromMessages для пересчета счетчиков из messages 📁 SAVED MESSAGES: Использует
* специальный метод для saved messages
*/
suspend fun upsertDialog(
opponentKey: String,
@@ -394,16 +488,18 @@ if (currentAccount == publicKey) {
// Обновляем информацию о собеседнике если есть
if (opponentTitle.isNotEmpty()) {
dialogDao.updateOpponentInfo(currentAccount, opponentKey, opponentTitle, opponentUsername, verified)
dialogDao.updateOpponentInfo(
currentAccount,
opponentKey,
opponentTitle,
opponentUsername,
verified
)
}
} catch (e: Exception) {}
}
} catch (e: Exception) {
}
}
/**
* Конвертировать DialogUiModel в SearchUser для навигации
*/
/** Конвертировать DialogUiModel в SearchUser для навигации */
fun dialogToSearchUser(dialog: DialogUiModel): SearchUser {
return SearchUser(
title = dialog.opponentTitle,
@@ -415,8 +511,8 @@ if (currentAccount == publicKey) {
}
/**
* Удалить диалог и все сообщения с собеседником
* 🔥 ПОЛНОСТЬЮ очищает все данные - диалог, сообщения, кэш
* Удалить диалог и все сообщения с собеседником 🔥 ПОЛНОСТЬЮ очищает все данные - диалог,
* сообщения, кэш
*/
suspend fun deleteDialog(opponentKey: String) {
if (currentAccount.isEmpty()) return
@@ -430,7 +526,8 @@ if (currentAccount == publicKey) {
_requestsCount.value = _requests.value.size
// Вычисляем правильный dialog_key (отсортированная комбинация ключей)
val dialogKey = if (currentAccount < opponentKey) {
val dialogKey =
if (currentAccount < opponentKey) {
"$currentAccount:$opponentKey"
} else {
"$opponentKey:$currentAccount"
@@ -442,16 +539,18 @@ if (currentAccount == publicKey) {
ChatViewModel.clearCacheForOpponent(opponentKey)
// 🗑️ 2. Проверяем сколько сообщений в БД до удаления
val messageCountBefore = database.messageDao().getMessageCount(currentAccount, dialogKey)
val messageCountBefore =
database.messageDao().getMessageCount(currentAccount, dialogKey)
// 🗑️ 3. Удаляем все сообщения из диалога по dialog_key
val deletedByDialogKey = database.messageDao().deleteDialog(
account = currentAccount,
dialogKey = dialogKey
)
val deletedByDialogKey =
database.messageDao()
.deleteDialog(account = currentAccount, dialogKey = dialogKey)
// 🗑️ 4. Также удаляем по from/to ключам (на всякий случай - старые сообщения)
val deletedBetweenUsers = database.messageDao().deleteMessagesBetweenUsers(
val deletedBetweenUsers =
database.messageDao()
.deleteMessagesBetweenUsers(
account = currentAccount,
user1 = opponentKey,
user2 = currentAccount
@@ -461,60 +560,51 @@ if (currentAccount == publicKey) {
val messageCountAfter = database.messageDao().getMessageCount(currentAccount, dialogKey)
// 🗑️ 6. Удаляем диалог из таблицы dialogs
database.dialogDao().deleteDialog(
account = currentAccount,
opponentKey = opponentKey
)
database.dialogDao().deleteDialog(account = currentAccount, opponentKey = opponentKey)
// 🗑️ 7. Проверяем что диалог удален
val dialogAfter = database.dialogDao().getDialog(currentAccount, opponentKey)
} catch (e: Exception) {
// В случае ошибки - возвращаем диалог обратно (откатываем оптимистичное обновление)
// Flow обновится автоматически из БД
}
}
/**
* Заблокировать пользователя
*/
/** Заблокировать пользователя */
suspend fun blockUser(publicKey: String) {
if (currentAccount.isEmpty()) return
try {
database.blacklistDao().blockUser(
database.blacklistDao()
.blockUser(
com.rosetta.messenger.database.BlacklistEntity(
publicKey = publicKey,
account = currentAccount
)
)
} catch (e: Exception) {
}
} catch (e: Exception) {}
}
/**
* Разблокировать пользователя
*/
/** Разблокировать пользователя */
suspend fun unblockUser(publicKey: String) {
if (currentAccount.isEmpty()) return
try {
database.blacklistDao().unblockUser(publicKey, currentAccount)
} catch (e: Exception) {
}
} catch (e: Exception) {}
}
/**
* 📬 Загрузить информацию о пользователе для request
* 📁 НЕ загружаем для Saved Messages (свой publicKey)
* 📬 Загрузить информацию о пользователе для request 📁 НЕ загружаем для Saved Messages (свой
* publicKey)
*/
private fun loadUserInfoForRequest(publicKey: String) {
loadUserInfoForDialog(publicKey)
}
/**
* 🔥 Загрузить информацию о пользователе для диалога
* 📁 НЕ загружаем для Saved Messages (свой publicKey)
* 🔥 Загрузить информацию о пользователе для диалога 📁 НЕ загружаем для Saved Messages (свой
* publicKey)
*/
private fun loadUserInfoForDialog(publicKey: String) {
// 📁 Не запрашиваем информацию о самом себе (Saved Messages)
@@ -528,10 +618,11 @@ if (currentAccount == publicKey) {
}
requestedUserInfoKeys.add(publicKey)
viewModelScope.launch(Dispatchers.IO) {
try {
val sharedPrefs = getApplication<Application>().getSharedPreferences("rosetta", Application.MODE_PRIVATE)
val sharedPrefs =
getApplication<Application>()
.getSharedPreferences("rosetta", Application.MODE_PRIVATE)
val currentUserPrivateKey = sharedPrefs.getString("private_key", "") ?: ""
if (currentUserPrivateKey.isEmpty()) return@launch
@@ -539,21 +630,18 @@ if (currentAccount == publicKey) {
// 🔥 ВАЖНО: Используем хеш ключа, как в MessageRepository.requestUserInfo
val privateKeyHash = CryptoManager.generatePrivateKeyHash(currentUserPrivateKey)
// Запрашиваем информацию о пользователе с сервера
val packet = PacketSearch().apply {
val packet =
PacketSearch().apply {
this.privateKey = privateKeyHash
this.search = publicKey
}
ProtocolManager.send(packet)
} catch (e: Exception) {
}
} catch (e: Exception) {}
}
}
/**
* Проверить заблокирован ли пользователь
*/
/** Проверить заблокирован ли пользователь */
suspend fun isUserBlocked(publicKey: String): Boolean {
if (currentAccount.isEmpty()) return false

View File

@@ -1,5 +1,7 @@
package com.rosetta.messenger.ui.components
import android.content.Context
import android.view.inputmethod.InputMethodManager
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.*
@@ -16,8 +18,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import android.content.Context
import android.view.inputmethod.InputMethodManager
import kotlinx.coroutines.launch
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
@@ -33,8 +33,7 @@ private const val EDGE_ZONE_DP = 200
/**
* Telegram-style swipe back container (optimized)
*
* Wraps content and allows swiping from the left edge to go back.
* Features:
* Wraps content and allows swiping from the left edge to go back. Features:
* - Edge-only swipe detection (left 30dp)
* - Direct state update during drag (no coroutine overhead)
* - VelocityTracker for fling detection
@@ -51,6 +50,12 @@ fun SwipeBackContainer(
swipeEnabled: Boolean = true,
content: @Composable () -> Unit
) {
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
// Saves ~15 remember/Animatable allocations per unused screen at startup (×12 screens).
var wasEverVisible by remember { mutableStateOf(false) }
if (isVisible) wasEverVisible = true
if (!wasEverVisible) return
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
@@ -74,7 +79,8 @@ fun SwipeBackContainer(
// Coroutine scope for animations
val scope = rememberCoroutineScope()
// 🔥 Keyboard controller для скрытия клавиатуры при свайпе (надёжный метод через InputMethodManager)
// 🔥 Keyboard controller для скрытия клавиатуры при свайпе (надёжный метод через
// InputMethodManager)
val context = LocalContext.current
val view = LocalView.current
val focusManager = LocalFocusManager.current
@@ -98,7 +104,8 @@ fun SwipeBackContainer(
alphaAnimatable.snapTo(0f)
alphaAnimatable.animateTo(
targetValue = 1f,
animationSpec = tween(
animationSpec =
tween(
durationMillis = ANIMATION_DURATION_ENTER,
easing = FastOutSlowInEasing
)
@@ -110,10 +117,7 @@ fun SwipeBackContainer(
alphaAnimatable.snapTo(1f)
alphaAnimatable.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = 200,
easing = FastOutSlowInEasing
)
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)
)
shouldShow = false
isAnimatingOut = false
@@ -128,17 +132,13 @@ fun SwipeBackContainer(
Box(modifier = Modifier.fillMaxSize()) {
// Scrim (dimming layer behind the screen) - only when swiping
if (currentOffset > 0f) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = scrimAlpha))
)
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = scrimAlpha)))
}
// Content with swipe gesture
Box(
modifier = Modifier
.fillMaxSize()
modifier =
Modifier.fillMaxSize()
.graphicsLayer {
translationX = currentOffset
alpha = currentAlpha
@@ -151,7 +151,10 @@ fun SwipeBackContainer(
val touchSlop = viewConfiguration.touchSlop
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val down =
awaitFirstDown(
requireUnconsumed = false
)
// Edge-only detection
if (down.position.x > edgeZonePx) {
@@ -166,8 +169,14 @@ fun SwipeBackContainer(
// Use Initial pass to intercept BEFORE children
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
val change = event.changes.firstOrNull { it.id == down.id }
val event =
awaitPointerEvent(
PointerEventPass.Initial
)
val change =
event.changes.firstOrNull {
it.id == down.id
}
?: break
if (change.changedToUpIgnoreConsumed()) {
@@ -179,29 +188,55 @@ fun SwipeBackContainer(
totalDragY += dragDelta.y
if (!passedSlop) {
val totalDistance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
val totalDistance =
kotlin.math.sqrt(
totalDragX *
totalDragX +
totalDragY *
totalDragY
)
if (totalDistance < touchSlop) continue
// Slop exceeded — only claim rightward + mostly horizontal
if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.5f) {
// Slop exceeded — only claim rightward
// + mostly horizontal
if (totalDragX > 0 &&
kotlin.math.abs(
totalDragX
) >
kotlin.math.abs(
totalDragY
) * 1.5f
) {
passedSlop = true
startedSwipe = true
isDragging = true
dragOffset = offsetAnimatable.value
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
val imm =
context.getSystemService(
Context.INPUT_METHOD_SERVICE
) as
InputMethodManager
imm.hideSoftInputFromWindow(
view.windowToken,
0
)
focusManager.clearFocus()
change.consume()
} else {
// Vertical or leftward — let children handle
// Vertical or leftward — let
// children handle
break
}
} else {
// We own the gesture — update drag
dragOffset = (dragOffset + dragDelta.x)
.coerceIn(0f, screenWidthPx)
dragOffset =
(dragOffset + dragDelta.x)
.coerceIn(
0f,
screenWidthPx
)
velocityTracker.addPosition(
change.uptimeMillis,
change.position
@@ -213,14 +248,22 @@ fun SwipeBackContainer(
// Handle drag end
if (startedSwipe) {
isDragging = false
val velocity = velocityTracker.calculateVelocity().x
val currentProgress = dragOffset / screenWidthPx
val velocity =
velocityTracker.calculateVelocity()
.x
val currentProgress =
dragOffset / screenWidthPx
val shouldComplete =
currentProgress > 0.5f || // Past 50% — always complete
velocity > FLING_VELOCITY_THRESHOLD || // Fast fling right
(currentProgress > COMPLETION_THRESHOLD &&
velocity > -FLING_VELOCITY_THRESHOLD) // 20%+ and not flinging back
currentProgress >
0.5f || // Past 50% — always
// complete
velocity >
FLING_VELOCITY_THRESHOLD || // Fast fling right
(currentProgress >
COMPLETION_THRESHOLD &&
velocity >
-FLING_VELOCITY_THRESHOLD) // 20%+ and not flinging back
scope.launch {
offsetAnimatable.snapTo(dragOffset)
@@ -228,18 +271,24 @@ fun SwipeBackContainer(
if (shouldComplete) {
offsetAnimatable.animateTo(
targetValue = screenWidthPx,
animationSpec = tween(
durationMillis = ANIMATION_DURATION_EXIT,
easing = TelegramEasing
animationSpec =
tween(
durationMillis =
ANIMATION_DURATION_EXIT,
easing =
TelegramEasing
)
)
onBack()
} else {
offsetAnimatable.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = ANIMATION_DURATION_EXIT,
easing = TelegramEasing
animationSpec =
tween(
durationMillis =
ANIMATION_DURATION_EXIT,
easing =
TelegramEasing
)
)
}
@@ -253,8 +302,6 @@ fun SwipeBackContainer(
Modifier
}
)
) {
content()
}
) { content() }
}
}