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.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,43 +136,28 @@ 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 при смене аккаунта
|
||||||
if (currentAccount != publicKey) {
|
if (currentAccount != publicKey) {
|
||||||
requestedUserInfoKeys.clear()
|
requestedUserInfoKeys.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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,12 +110,10 @@ 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) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,56 +236,86 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||||
.collect { decryptedDialogs ->
|
.collect { decryptedDialogs ->
|
||||||
_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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user