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:
@@ -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,43 +136,28 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация с текущим аккаунтом
|
||||
*/
|
||||
/** Инициализация с текущим аккаунтом */
|
||||
fun initialize(publicKey: String, privateKey: String) {
|
||||
val start = System.currentTimeMillis()
|
||||
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
||||
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
||||
if (currentAccount != publicKey) {
|
||||
requestedUserInfoKeys.clear()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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,12 +110,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
|
||||
private val TAG = "ChatsListVM"
|
||||
|
||||
/**
|
||||
* Установить текущий аккаунт и загрузить диалоги
|
||||
*/
|
||||
/** Установить текущий аккаунт и загрузить диалоги */
|
||||
fun setAccount(publicKey: String, privateKey: String) {
|
||||
val setAccountStart = System.currentTimeMillis()
|
||||
if (currentAccount == publicKey) {
|
||||
if (currentAccount == publicKey) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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,56 +236,86 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||
.collect { decryptedDialogs ->
|
||||
_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
|
||||
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user