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

View File

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

View File

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

View File

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