feat: implement debug logging functionality and UI for message processing
This commit is contained in:
@@ -7,6 +7,7 @@ import com.rosetta.messenger.database.*
|
|||||||
import com.rosetta.messenger.network.*
|
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 kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
@@ -85,6 +86,28 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var INSTANCE: MessageRepository? = null
|
private var INSTANCE: MessageRepository? = null
|
||||||
|
|
||||||
|
// 🔥 In-memory кэш обработанных messageId для предотвращения дубликатов
|
||||||
|
// LRU кэш с ограничением 1000 элементов - защита от race conditions
|
||||||
|
private val processedMessageIds = java.util.Collections.synchronizedSet(
|
||||||
|
object : LinkedHashSet<String>() {
|
||||||
|
override fun add(element: String): Boolean {
|
||||||
|
if (size >= 1000) remove(first())
|
||||||
|
return super.add(element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Помечает messageId как обработанный и возвращает true если это новый ID
|
||||||
|
* Возвращает false если сообщение уже было обработано (дубликат)
|
||||||
|
*/
|
||||||
|
fun markAsProcessed(messageId: String): Boolean = processedMessageIds.add(messageId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очистка кэша (вызывается при logout)
|
||||||
|
*/
|
||||||
|
fun clearProcessedCache() = processedMessageIds.clear()
|
||||||
|
|
||||||
fun getInstance(context: Context): MessageRepository {
|
fun getInstance(context: Context): MessageRepository {
|
||||||
return INSTANCE ?: synchronized(this) {
|
return INSTANCE ?: synchronized(this) {
|
||||||
INSTANCE ?: MessageRepository(context.applicationContext).also { INSTANCE = it }
|
INSTANCE ?: MessageRepository(context.applicationContext).also { INSTANCE = it }
|
||||||
@@ -175,6 +198,16 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
// 📁 Проверяем является ли это Saved Messages
|
// 📁 Проверяем является ли это Saved Messages
|
||||||
val isSavedMessages = (account == toPublicKey)
|
val isSavedMessages = (account == toPublicKey)
|
||||||
|
|
||||||
|
// 📝 LOG: Начало отправки
|
||||||
|
MessageLogger.logSendStart(
|
||||||
|
messageId = messageId,
|
||||||
|
toPublicKey = toPublicKey,
|
||||||
|
textLength = text.trim().length,
|
||||||
|
attachmentsCount = attachments.size,
|
||||||
|
isSavedMessages = isSavedMessages,
|
||||||
|
replyToMessageId = replyToMessageId
|
||||||
|
)
|
||||||
|
|
||||||
// 1. Создаем оптимистичное сообщение
|
// 1. Создаем оптимистичное сообщение
|
||||||
// 📁 Для saved messages - сразу DELIVERED и прочитано
|
// 📁 Для saved messages - сразу DELIVERED и прочитано
|
||||||
val optimisticMessage = Message(
|
val optimisticMessage = Message(
|
||||||
@@ -192,9 +225,11 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
|
|
||||||
// 2. Обновляем UI сразу (Optimistic Update)
|
// 2. Обновляем UI сразу (Optimistic Update)
|
||||||
updateMessageCache(dialogKey, optimisticMessage)
|
updateMessageCache(dialogKey, optimisticMessage)
|
||||||
|
MessageLogger.logCacheUpdate(dialogKey, messageCache[dialogKey]?.value?.size ?: 0)
|
||||||
|
|
||||||
// 3. Фоновая обработка
|
// 3. Фоновая обработка
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
try {
|
try {
|
||||||
// Шифрование
|
// Шифрование
|
||||||
val encryptResult = MessageCrypto.encryptForSending(
|
val encryptResult = MessageCrypto.encryptForSending(
|
||||||
@@ -204,6 +239,13 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
val encryptedContent = encryptResult.ciphertext
|
val encryptedContent = encryptResult.ciphertext
|
||||||
val encryptedKey = encryptResult.encryptedKey
|
val encryptedKey = encryptResult.encryptedKey
|
||||||
|
|
||||||
|
// 📝 LOG: Шифрование успешно
|
||||||
|
MessageLogger.logEncryptionSuccess(
|
||||||
|
messageId = messageId,
|
||||||
|
encryptedContentLength = encryptedContent.length,
|
||||||
|
encryptedKeyLength = encryptedKey.length
|
||||||
|
)
|
||||||
|
|
||||||
// 🔑 КРИТИЧНО: Сохраняем ЗАШИФРОВАННЫЙ chacha_key (как в Desktop!)
|
// 🔑 КРИТИЧНО: Сохраняем ЗАШИФРОВАННЫЙ chacha_key (как в Desktop!)
|
||||||
// Desktop хранит зашифрованный ключ, расшифровывает только при использовании
|
// Desktop хранит зашифрованный ключ, расшифровывает только при использовании
|
||||||
|
|
||||||
@@ -236,6 +278,10 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
)
|
)
|
||||||
messageDao.insertMessage(entity)
|
messageDao.insertMessage(entity)
|
||||||
|
|
||||||
|
// 📝 LOG: Сохранено в БД
|
||||||
|
MessageLogger.logDbSave(messageId, dialogKey, isNew = true)
|
||||||
|
} else {
|
||||||
|
MessageLogger.logDbSave(messageId, dialogKey, isNew = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
|
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
|
||||||
@@ -243,6 +289,11 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
|
|
||||||
// 🔥 Логируем что записалось в диалог
|
// 🔥 Логируем что записалось в диалог
|
||||||
val dialog = dialogDao.getDialog(account, toPublicKey)
|
val dialog = dialogDao.getDialog(account, toPublicKey)
|
||||||
|
MessageLogger.logDialogUpdate(
|
||||||
|
dialogKey = dialogKey,
|
||||||
|
lastMessage = dialog?.lastMessage,
|
||||||
|
unreadCount = dialog?.unreadCount ?: 0
|
||||||
|
)
|
||||||
|
|
||||||
// 🔥 Отмечаем что я отправлял сообщения в этот диалог (перемещает из requests в chats)
|
// 🔥 Отмечаем что я отправлял сообщения в этот диалог (перемещает из requests в chats)
|
||||||
val updatedRows = dialogDao.markIHaveSent(account, toPublicKey)
|
val updatedRows = dialogDao.markIHaveSent(account, toPublicKey)
|
||||||
@@ -250,6 +301,8 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
// 📁 НЕ отправляем пакет на сервер для saved messages!
|
// 📁 НЕ отправляем пакет на сервер для saved messages!
|
||||||
// Как в Архиве: if(publicKey == opponentPublicKey) return;
|
// Как в Архиве: if(publicKey == opponentPublicKey) return;
|
||||||
if (isSavedMessages) {
|
if (isSavedMessages) {
|
||||||
|
MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
|
||||||
|
MessageLogger.debug("📁 SavedMessages: skipping server send")
|
||||||
return@launch // Для saved messages - только локальное сохранение, без отправки на сервер
|
return@launch // Для saved messages - только локальное сохранение, без отправки на сервер
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,9 +318,18 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
this.attachments = attachments
|
this.attachments = attachments
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📝 LOG: Отправка пакета
|
||||||
|
MessageLogger.logPacketSend(messageId, toPublicKey, timestamp)
|
||||||
|
|
||||||
ProtocolManager.send(packet)
|
ProtocolManager.send(packet)
|
||||||
|
|
||||||
|
// 📝 LOG: Успешная отправка
|
||||||
|
MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
// 📝 LOG: Ошибка отправки
|
||||||
|
MessageLogger.logSendError(messageId, e)
|
||||||
|
|
||||||
// При ошибке обновляем статус
|
// При ошибке обновляем статус
|
||||||
messageDao.updateDeliveryStatus(account, messageId, DeliveryStatus.ERROR.value)
|
messageDao.updateDeliveryStatus(account, messageId, DeliveryStatus.ERROR.value)
|
||||||
updateMessageStatus(dialogKey, messageId, DeliveryStatus.ERROR)
|
updateMessageStatus(dialogKey, messageId, DeliveryStatus.ERROR)
|
||||||
@@ -281,29 +343,53 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
* Обработка входящего сообщения
|
* Обработка входящего сообщения
|
||||||
*/
|
*/
|
||||||
suspend fun handleIncomingMessage(packet: PacketMessage) {
|
suspend fun handleIncomingMessage(packet: PacketMessage) {
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
|
||||||
val account = currentAccount ?: run {
|
val account = currentAccount ?: run {
|
||||||
|
MessageLogger.debug("📥 RECEIVE SKIP: account is null")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val privateKey = currentPrivateKey ?: run {
|
val privateKey = currentPrivateKey ?: run {
|
||||||
|
MessageLogger.debug("📥 RECEIVE SKIP: privateKey is null")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📝 LOG: Начало обработки входящего сообщения
|
||||||
|
MessageLogger.logReceiveStart(
|
||||||
|
messageId = packet.messageId,
|
||||||
|
fromPublicKey = packet.fromPublicKey,
|
||||||
|
toPublicKey = packet.toPublicKey,
|
||||||
|
contentLength = packet.content.length,
|
||||||
|
attachmentsCount = packet.attachments.size,
|
||||||
|
timestamp = packet.timestamp
|
||||||
|
)
|
||||||
|
|
||||||
// 🔥 Проверяем, не заблокирован ли отправитель
|
// 🔥 Проверяем, не заблокирован ли отправитель
|
||||||
val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account)
|
val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account)
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
|
MessageLogger.logBlockedSender(packet.fromPublicKey)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 Генерируем messageId если он пустой (как в Архиве - generateRandomKeyFormSeed)
|
// 🔥 Генерируем messageId если он пустой (как в Архиве - generateRandomKeyFormSeed)
|
||||||
val messageId = if (packet.messageId.isBlank()) {
|
val messageId = if (packet.messageId.isBlank()) {
|
||||||
generateMessageId(packet.fromPublicKey, packet.toPublicKey, packet.timestamp)
|
val generatedId = generateMessageId(packet.fromPublicKey, packet.toPublicKey, packet.timestamp)
|
||||||
|
MessageLogger.debug("📥 Generated messageId: $generatedId (original was blank)")
|
||||||
|
generatedId
|
||||||
} else {
|
} else {
|
||||||
packet.messageId
|
packet.messageId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем, не дубликат ли (используем сгенерированный messageId)
|
// 🔥 ПЕРВЫЙ УРОВЕНЬ ЗАЩИТЫ: In-memory кэш (быстрая проверка без обращения к БД)
|
||||||
|
// markAsProcessed возвращает false если сообщение уже обрабатывалось
|
||||||
|
if (!markAsProcessed(messageId)) {
|
||||||
|
MessageLogger.debug("📥 SKIP (in-memory cache): Message $messageId already being processed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 ВТОРОЙ УРОВЕНЬ ЗАЩИТЫ: Проверка в БД (для сообщений сохранённых в предыдущих сессиях)
|
||||||
val isDuplicate = messageDao.messageExists(account, messageId)
|
val isDuplicate = messageDao.messageExists(account, messageId)
|
||||||
|
MessageLogger.logDuplicateCheck(messageId, isDuplicate)
|
||||||
if (isDuplicate) {
|
if (isDuplicate) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -322,6 +408,13 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
privateKey
|
privateKey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 📝 LOG: Расшифровка успешна
|
||||||
|
MessageLogger.logDecryptionSuccess(
|
||||||
|
messageId = messageId,
|
||||||
|
plainTextLength = plainText.length,
|
||||||
|
attachmentsCount = packet.attachments.size
|
||||||
|
)
|
||||||
|
|
||||||
// Сериализуем attachments в JSON с расшифровкой MESSAGES blob
|
// Сериализуем attachments в JSON с расшифровкой MESSAGES blob
|
||||||
val attachmentsJson = serializeAttachmentsWithDecryption(
|
val attachmentsJson = serializeAttachmentsWithDecryption(
|
||||||
packet.attachments,
|
packet.attachments,
|
||||||
@@ -335,7 +428,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
|
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
|
||||||
processAvatarAttachments(packet.attachments, packet.fromPublicKey, packet.chachaKey, privateKey)
|
processAvatarAttachments(packet.attachments, packet.fromPublicKey, packet.chachaKey, privateKey)
|
||||||
|
|
||||||
// <EFBFBD>🔒 Шифруем plainMessage с использованием приватного ключа
|
// 🔒 Шифруем plainMessage с использованием приватного ключа
|
||||||
val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey)
|
val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey)
|
||||||
|
|
||||||
// Создаем entity для кэша и возможной вставки
|
// Создаем entity для кэша и возможной вставки
|
||||||
@@ -361,7 +454,9 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
if (!stillExists) {
|
if (!stillExists) {
|
||||||
// Сохраняем в БД только если сообщения нет
|
// Сохраняем в БД только если сообщения нет
|
||||||
messageDao.insertMessage(entity)
|
messageDao.insertMessage(entity)
|
||||||
|
MessageLogger.logDbSave(messageId, dialogKey, isNew = true)
|
||||||
|
} else {
|
||||||
|
MessageLogger.logDbSave(messageId, dialogKey, isNew = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
|
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
|
||||||
@@ -369,6 +464,11 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
|
|
||||||
// 🔥 Логируем что записалось в диалог
|
// 🔥 Логируем что записалось в диалог
|
||||||
val dialog = dialogDao.getDialog(account, packet.fromPublicKey)
|
val dialog = dialogDao.getDialog(account, packet.fromPublicKey)
|
||||||
|
MessageLogger.logDialogUpdate(
|
||||||
|
dialogKey = dialogKey,
|
||||||
|
lastMessage = dialog?.lastMessage,
|
||||||
|
unreadCount = dialog?.unreadCount ?: 0
|
||||||
|
)
|
||||||
|
|
||||||
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа
|
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа
|
||||||
requestUserInfo(packet.fromPublicKey)
|
requestUserInfo(packet.fromPublicKey)
|
||||||
@@ -377,12 +477,18 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
if (!stillExists) {
|
if (!stillExists) {
|
||||||
val message = entity.toMessage()
|
val message = entity.toMessage()
|
||||||
updateMessageCache(dialogKey, message)
|
updateMessageCache(dialogKey, message)
|
||||||
|
MessageLogger.logCacheUpdate(dialogKey, messageCache[dialogKey]?.value?.size ?: 0)
|
||||||
|
|
||||||
// 🔔 Уведомляем UI о новом сообщении (emit dialogKey для обновления)
|
// 🔔 Уведомляем UI о новом сообщении (emit dialogKey для обновления)
|
||||||
_newMessageEvents.tryEmit(dialogKey)
|
_newMessageEvents.tryEmit(dialogKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📝 LOG: Успешная обработка
|
||||||
|
MessageLogger.logReceiveSuccess(messageId, System.currentTimeMillis() - startTime)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
// 📝 LOG: Ошибка обработки
|
||||||
|
MessageLogger.logDecryptionError(messageId, e)
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,6 +498,14 @@ 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
|
||||||
|
|
||||||
|
// 📝 LOG: Получено подтверждение доставки
|
||||||
|
MessageLogger.logDeliveryStatus(
|
||||||
|
messageId = packet.messageId,
|
||||||
|
toPublicKey = packet.toPublicKey,
|
||||||
|
status = "DELIVERED"
|
||||||
|
)
|
||||||
|
|
||||||
messageDao.updateDeliveryStatus(account, packet.messageId, DeliveryStatus.DELIVERED.value)
|
messageDao.updateDeliveryStatus(account, packet.messageId, DeliveryStatus.DELIVERED.value)
|
||||||
|
|
||||||
// Обновляем кэш
|
// Обновляем кэш
|
||||||
@@ -410,6 +524,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
suspend fun handleRead(packet: PacketRead) {
|
suspend fun handleRead(packet: PacketRead) {
|
||||||
val account = currentAccount ?: return
|
val account = currentAccount ?: return
|
||||||
|
|
||||||
|
MessageLogger.debug("👁 READ PACKET from: ${packet.fromPublicKey.take(16)}...")
|
||||||
|
|
||||||
// Проверяем последнее сообщение ДО обновления
|
// Проверяем последнее сообщение ДО обновления
|
||||||
val lastMsgBefore = messageDao.getLastMessageDebug(account, packet.fromPublicKey)
|
val lastMsgBefore = messageDao.getLastMessageDebug(account, packet.fromPublicKey)
|
||||||
@@ -422,6 +537,7 @@ 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
|
||||||
messageCache[dialogKey]?.let { flow ->
|
messageCache[dialogKey]?.let { flow ->
|
||||||
flow.value = flow.value.map { msg ->
|
flow.value = flow.value.map { msg ->
|
||||||
if (msg.isFromMe && !msg.isRead) msg.copy(isRead = true)
|
if (msg.isFromMe && !msg.isRead) msg.copy(isRead = true)
|
||||||
@@ -429,6 +545,12 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📝 LOG: Статус прочтения
|
||||||
|
MessageLogger.logReadStatus(
|
||||||
|
fromPublicKey = packet.fromPublicKey,
|
||||||
|
messagesCount = readCount
|
||||||
|
)
|
||||||
|
|
||||||
// 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageRead обновился
|
// 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageRead обновился
|
||||||
dialogDao.updateDialogFromMessages(account, packet.fromPublicKey)
|
dialogDao.updateDialogFromMessages(account, packet.fromPublicKey)
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import com.airbnb.lottie.compose.rememberLottieComposition
|
|||||||
import com.rosetta.messenger.R
|
import com.rosetta.messenger.R
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
import com.rosetta.messenger.network.AttachmentType
|
import com.rosetta.messenger.network.AttachmentType
|
||||||
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.components.AvatarImage
|
import com.rosetta.messenger.ui.components.AvatarImage
|
||||||
import com.rosetta.messenger.ui.chats.models.*
|
import com.rosetta.messenger.ui.chats.models.*
|
||||||
@@ -283,6 +284,15 @@ fun ChatDetailScreen(
|
|||||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||||
var showBlockConfirm by remember { mutableStateOf(false) }
|
var showBlockConfirm by remember { mutableStateOf(false) }
|
||||||
var showUnblockConfirm by remember { mutableStateOf(false) }
|
var showUnblockConfirm by remember { mutableStateOf(false) }
|
||||||
|
var showDebugLogs by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Debug logs из ProtocolManager
|
||||||
|
val debugLogs by ProtocolManager.debugLogs.collectAsState()
|
||||||
|
|
||||||
|
// Включаем UI логи только когда открыт bottom sheet
|
||||||
|
LaunchedEffect(showDebugLogs) {
|
||||||
|
ProtocolManager.enableUILogs(showDebugLogs)
|
||||||
|
}
|
||||||
|
|
||||||
// Наблюдаем за статусом блокировки в реальном времени через Flow
|
// Наблюдаем за статусом блокировки в реальном времени через Flow
|
||||||
val isBlocked by database.blacklistDao()
|
val isBlocked by database.blacklistDao()
|
||||||
@@ -864,20 +874,20 @@ fun ChatDetailScreen(
|
|||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.size(40.dp)
|
Modifier.size(40.dp)
|
||||||
.clickable(
|
.then(
|
||||||
indication =
|
if (!isSavedMessages) {
|
||||||
null,
|
Modifier.clickable(
|
||||||
interactionSource =
|
indication = null,
|
||||||
remember {
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
MutableInteractionSource()
|
) {
|
||||||
|
// Мгновенное закрытие клавиатуры через нативный API
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
|
focusManager.clearFocus()
|
||||||
|
onUserProfileClick(user)
|
||||||
}
|
}
|
||||||
) {
|
} else Modifier
|
||||||
// Мгновенное закрытие клавиатуры через нативный API
|
),
|
||||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
|
||||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
|
||||||
focusManager.clearFocus()
|
|
||||||
onUserProfileClick(user)
|
|
||||||
},
|
|
||||||
contentAlignment =
|
contentAlignment =
|
||||||
Alignment.Center
|
Alignment.Center
|
||||||
) {
|
) {
|
||||||
@@ -924,20 +934,20 @@ fun ChatDetailScreen(
|
|||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.weight(1f)
|
Modifier.weight(1f)
|
||||||
.clickable(
|
.then(
|
||||||
indication =
|
if (!isSavedMessages) {
|
||||||
null,
|
Modifier.clickable(
|
||||||
interactionSource =
|
indication = null,
|
||||||
remember {
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
MutableInteractionSource()
|
) {
|
||||||
|
// Мгновенное закрытие клавиатуры через нативный API
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
|
focusManager.clearFocus()
|
||||||
|
onUserProfileClick(user)
|
||||||
}
|
}
|
||||||
) {
|
} else Modifier
|
||||||
// Мгновенное закрытие клавиатуры через нативный API
|
)
|
||||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
|
||||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
|
||||||
focusManager.clearFocus()
|
|
||||||
onUserProfileClick(user)
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment =
|
verticalAlignment =
|
||||||
@@ -1093,6 +1103,12 @@ fun ChatDetailScreen(
|
|||||||
false
|
false
|
||||||
showDeleteConfirm =
|
showDeleteConfirm =
|
||||||
true
|
true
|
||||||
|
},
|
||||||
|
onLogsClick = {
|
||||||
|
showMenu =
|
||||||
|
false
|
||||||
|
showDebugLogs =
|
||||||
|
true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2115,6 +2131,16 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🐛 Debug Logs BottomSheet
|
||||||
|
if (showDebugLogs) {
|
||||||
|
DebugLogsBottomSheet(
|
||||||
|
logs = debugLogs,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
onDismiss = { showDebugLogs = false },
|
||||||
|
onClearLogs = { ProtocolManager.clearLogs() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 📨 Forward Chat Picker BottomSheet
|
// 📨 Forward Chat Picker BottomSheet
|
||||||
if (showForwardPicker) {
|
if (showForwardPicker) {
|
||||||
ForwardChatPickerBottomSheet(
|
ForwardChatPickerBottomSheet(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import com.rosetta.messenger.database.RosettaDatabase
|
|||||||
import com.rosetta.messenger.network.*
|
import com.rosetta.messenger.network.*
|
||||||
import com.rosetta.messenger.ui.chats.models.*
|
import com.rosetta.messenger.ui.chats.models.*
|
||||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||||
|
import com.rosetta.messenger.utils.MessageThrottleManager
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
@@ -146,10 +147,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
// Защита от двойной отправки
|
// Защита от двойной отправки
|
||||||
private var isSending = false
|
private var isSending = false
|
||||||
|
// 🔥 Throttling перенесён в глобальный MessageThrottleManager
|
||||||
// 🔥 Throttling для отправки сообщений (защита от спама)
|
|
||||||
private var lastMessageSentTime = 0L
|
|
||||||
private val MESSAGE_THROTTLE_MS = 100L // Минимум 100ms между сообщениями
|
|
||||||
|
|
||||||
// Job для отмены загрузки при смене диалога
|
// Job для отмены загрузки при смене диалога
|
||||||
private var loadingJob: Job? = null
|
private var loadingJob: Job? = null
|
||||||
@@ -170,6 +168,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// 🟢 Флаг что уже подписаны на онлайн статус собеседника
|
// 🟢 Флаг что уже подписаны на онлайн статус собеседника
|
||||||
private var subscribedToOnlineStatus = false
|
private var subscribedToOnlineStatus = false
|
||||||
|
|
||||||
|
// 🔥 Сохраняем ссылки на обработчики для очистки в onCleared()
|
||||||
|
// ВАЖНО: Должны быть определены ДО init блока!
|
||||||
|
private val typingPacketHandler: (Packet) -> Unit = { packet ->
|
||||||
|
val typingPacket = packet as PacketTyping
|
||||||
|
if (typingPacket.fromPublicKey == opponentKey) {
|
||||||
|
showTypingIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val onlinePacketHandler: (Packet) -> Unit = { packet ->
|
||||||
|
val onlinePacket = packet as PacketOnlineState
|
||||||
|
onlinePacket.publicKeysState.forEach { item ->
|
||||||
|
if (item.publicKey == opponentKey) {
|
||||||
|
_opponentOnline.value = item.state == OnlineState.ONLINE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setupPacketListeners()
|
setupPacketListeners()
|
||||||
setupNewMessageListener()
|
setupNewMessageListener()
|
||||||
@@ -301,67 +317,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setupPacketListeners() {
|
private fun setupPacketListeners() {
|
||||||
// ✅ Входящие сообщения обрабатываются ТОЛЬКО в MessageRepository (ProtocolManager)
|
// ✅ Обработчики 0x06, 0x07, 0x08 удалены - они обрабатываются ТОЛЬКО в ProtocolManager
|
||||||
// Здесь НЕ обрабатываем 0x06 чтобы избежать дублирования!
|
// Это предотвращает дублирование сообщений и статусов при навигации между чатами
|
||||||
// ChatViewModel получает обновления через loadMessagesFromDatabase() после сохранения в БД
|
// ChatViewModel получает обновления через messageCache Flow из MessageRepository
|
||||||
|
|
||||||
// Доставка
|
// Typing - нужен здесь для UI текущего чата
|
||||||
ProtocolManager.waitPacket(0x08) { packet ->
|
ProtocolManager.waitPacket(0x0B, typingPacketHandler)
|
||||||
val deliveryPacket = packet as PacketDelivery
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
// Обновляем в БД
|
|
||||||
updateMessageStatusInDb(deliveryPacket.messageId, 1) // DELIVERED
|
|
||||||
// Обновляем UI
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
updateMessageStatus(deliveryPacket.messageId, MessageStatus.DELIVERED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Прочитано - пакет сообщает что собеседник прочитал наши сообщения
|
// 🟢 Онлайн статус - нужен здесь для UI текущего чата
|
||||||
// В Desktop нет messageId - просто отмечаем все исходящие сообщения как прочитанные
|
ProtocolManager.waitPacket(0x05, onlinePacketHandler)
|
||||||
ProtocolManager.waitPacket(0x07) { packet ->
|
|
||||||
val readPacket = packet as PacketRead
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
// Если fromPublicKey == наш собеседник, значит он прочитал наши сообщения
|
|
||||||
if (readPacket.fromPublicKey == opponentKey) {
|
|
||||||
// Обновляем все непрочитанные исходящие сообщения в БД
|
|
||||||
val account = myPublicKey ?: return@launch
|
|
||||||
val opponent = opponentKey ?: return@launch
|
|
||||||
messageDao.markAllAsRead(account, opponent)
|
|
||||||
|
|
||||||
// Обновляем UI - все исходящие сообщения помечаем как прочитанные
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
_messages.value = _messages.value.map { msg ->
|
|
||||||
if (msg.isOutgoing && msg.status != MessageStatus.READ) {
|
|
||||||
msg.copy(status = MessageStatus.READ)
|
|
||||||
} else msg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typing
|
|
||||||
ProtocolManager.waitPacket(0x0B) { packet ->
|
|
||||||
val typingPacket = packet as PacketTyping
|
|
||||||
if (typingPacket.fromPublicKey == opponentKey) {
|
|
||||||
showTypingIndicator()
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🟢 Онлайн статус (массив publicKey+state как в React Native)
|
|
||||||
ProtocolManager.waitPacket(0x05) { packet ->
|
|
||||||
val onlinePacket = packet as PacketOnlineState
|
|
||||||
|
|
||||||
onlinePacket.publicKeysState.forEach { item ->
|
|
||||||
|
|
||||||
if (item.publicKey == opponentKey) {
|
|
||||||
_opponentOnline.value = item.state == OnlineState.ONLINE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ handleIncomingMessage удалён - обработка входящих сообщений теперь ТОЛЬКО в MessageRepository
|
// ✅ handleIncomingMessage удалён - обработка входящих сообщений теперь ТОЛЬКО в MessageRepository
|
||||||
@@ -1318,12 +1282,11 @@ val newList = messages + optimisticMessages
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 Throttling - защита от спама сообщениями
|
// 🔥 Глобальный throttle - защита от спама сообщениями (app-wide)
|
||||||
val now = System.currentTimeMillis()
|
val dialogKey = "$sender:$recipient"
|
||||||
if (now - lastMessageSentTime < MESSAGE_THROTTLE_MS) {
|
if (!MessageThrottleManager.canSendWithContent(dialogKey, text.hashCode())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastMessageSentTime = now
|
|
||||||
|
|
||||||
isSending = true
|
isSending = true
|
||||||
|
|
||||||
@@ -2788,6 +2751,11 @@ val newList = messages + optimisticMessages
|
|||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
|
|
||||||
|
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
|
||||||
|
ProtocolManager.unwaitPacket(0x0B, typingPacketHandler)
|
||||||
|
ProtocolManager.unwaitPacket(0x05, onlinePacketHandler)
|
||||||
|
|
||||||
lastReadMessageTimestamp = 0L
|
lastReadMessageTimestamp = 0L
|
||||||
readReceiptSentForCurrentDialog = false
|
readReceiptSentForCurrentDialog = false
|
||||||
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке
|
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке
|
||||||
|
|||||||
@@ -1162,7 +1162,8 @@ fun KebabMenu(
|
|||||||
isBlocked: Boolean,
|
isBlocked: Boolean,
|
||||||
onBlockClick: () -> Unit,
|
onBlockClick: () -> Unit,
|
||||||
onUnblockClick: () -> Unit,
|
onUnblockClick: () -> Unit,
|
||||||
onDeleteClick: () -> Unit
|
onDeleteClick: () -> Unit,
|
||||||
|
onLogsClick: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val dividerColor =
|
val dividerColor =
|
||||||
if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
|
if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
|
||||||
@@ -1196,6 +1197,23 @@ fun KebabMenu(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debug Logs
|
||||||
|
KebabMenuItem(
|
||||||
|
icon = TablerIcons.Bug,
|
||||||
|
text = "Debug Logs",
|
||||||
|
onClick = onLogsClick,
|
||||||
|
tintColor = PrimaryBlue,
|
||||||
|
textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
.height(0.5.dp)
|
||||||
|
.background(dividerColor)
|
||||||
|
)
|
||||||
|
|
||||||
KebabMenuItem(
|
KebabMenuItem(
|
||||||
icon = TablerIcons.Trash,
|
icon = TablerIcons.Trash,
|
||||||
text = "Delete Chat",
|
text = "Delete Chat",
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package com.rosetta.messenger.ui.chats.components
|
||||||
|
|
||||||
|
import android.view.HapticFeedbackConstants
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
import compose.icons.TablerIcons
|
||||||
|
import compose.icons.tablericons.Bug
|
||||||
|
import compose.icons.tablericons.Trash
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🐛 BottomSheet для отображения debug логов протокола
|
||||||
|
*
|
||||||
|
* Показывает логи отправки/получения сообщений для дебага.
|
||||||
|
* Использует ProtocolManager.debugLogs как источник данных.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DebugLogsBottomSheet(
|
||||||
|
logs: List<String>,
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onClearLogs: () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val view = LocalView.current
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
|
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
|
||||||
|
|
||||||
|
// Haptic feedback при открытии
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Авто-скролл вниз при новых логах
|
||||||
|
LaunchedEffect(logs.size) {
|
||||||
|
if (logs.isNotEmpty()) {
|
||||||
|
listState.animateScrollToItem(logs.size - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Плавное затемнение статус бара
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
if (!view.isInEditMode) {
|
||||||
|
val window = (view.context as? android.app.Activity)?.window
|
||||||
|
val originalStatusBarColor = window?.statusBarColor ?: 0
|
||||||
|
val scrimColor = android.graphics.Color.argb(153, 0, 0, 0)
|
||||||
|
|
||||||
|
val fadeInAnimator = android.animation.ValueAnimator.ofArgb(originalStatusBarColor, scrimColor).apply {
|
||||||
|
duration = 200
|
||||||
|
addUpdateListener { animator ->
|
||||||
|
window?.statusBarColor = animator.animatedValue as Int
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fadeInAnimator.start()
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
val fadeOutAnimator = android.animation.ValueAnimator.ofArgb(scrimColor, originalStatusBarColor).apply {
|
||||||
|
duration = 150
|
||||||
|
addUpdateListener { animator ->
|
||||||
|
window?.statusBarColor = animator.animatedValue as Int
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fadeOutAnimator.start()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onDispose { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissWithAnimation() {
|
||||||
|
scope.launch {
|
||||||
|
sheetState.hide()
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = { dismissWithAnimation() },
|
||||||
|
sheetState = sheetState,
|
||||||
|
containerColor = backgroundColor,
|
||||||
|
scrimColor = Color.Black.copy(alpha = 0.6f),
|
||||||
|
dragHandle = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(36.dp)
|
||||||
|
.height(5.dp)
|
||||||
|
.clip(RoundedCornerShape(2.5.dp))
|
||||||
|
.background(if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD1D1D6))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||||
|
modifier = Modifier.statusBarsPadding()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Иконка и заголовок
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
TablerIcons.Bug,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = PrimaryBlue,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Debug Logs",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${logs.size} log entries",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = secondaryTextColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопки
|
||||||
|
Row {
|
||||||
|
IconButton(onClick = onClearLogs) {
|
||||||
|
Icon(
|
||||||
|
TablerIcons.Trash,
|
||||||
|
contentDescription = "Clear logs",
|
||||||
|
tint = secondaryTextColor.copy(alpha = 0.6f),
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { dismissWithAnimation() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Close",
|
||||||
|
tint = secondaryTextColor.copy(alpha = 0.6f),
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Divider(color = dividerColor, thickness = 0.5.dp)
|
||||||
|
|
||||||
|
// Контент
|
||||||
|
if (logs.isEmpty()) {
|
||||||
|
// Empty state
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No logs yet.\nLogs will appear here during messaging.",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = secondaryTextColor,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Список логов
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 300.dp, max = 500.dp)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
items(logs) { log ->
|
||||||
|
DebugLogItem(log = log, isDarkTheme = isDarkTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Элемент лога с цветовой кодировкой
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DebugLogItem(
|
||||||
|
log: String,
|
||||||
|
isDarkTheme: Boolean
|
||||||
|
) {
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val successColor = Color(0xFF34C759)
|
||||||
|
val errorColor = Color(0xFFFF3B30)
|
||||||
|
val purpleColor = Color(0xFFAF52DE)
|
||||||
|
val heartbeatColor = Color(0xFFFF9500)
|
||||||
|
val messageColor = PrimaryBlue
|
||||||
|
|
||||||
|
// Определяем цвет по содержимому лога
|
||||||
|
val logColor = when {
|
||||||
|
log.contains("✅") || log.contains("SUCCESS") -> successColor
|
||||||
|
log.contains("❌") || log.contains("ERROR") || log.contains("FAILED") -> errorColor
|
||||||
|
log.contains("🔄") || log.contains("STATE") -> purpleColor
|
||||||
|
log.contains("💓") || log.contains("💔") -> heartbeatColor
|
||||||
|
log.contains("📥") || log.contains("📤") || log.contains("📨") -> messageColor
|
||||||
|
else -> textColor.copy(alpha = 0.85f)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = log,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = logColor,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp, horizontal = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,8 +11,13 @@ import androidx.compose.ui.graphics.graphicsLayer
|
|||||||
import androidx.compose.ui.input.pointer.*
|
import androidx.compose.ui.input.pointer.*
|
||||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.unit.dp
|
import 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)
|
||||||
@@ -69,6 +74,11 @@ fun SwipeBackContainer(
|
|||||||
// Coroutine scope for animations
|
// Coroutine scope for animations
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// 🔥 Keyboard controller для скрытия клавиатуры при свайпе (надёжный метод через InputMethodManager)
|
||||||
|
val context = LocalContext.current
|
||||||
|
val view = LocalView.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
// Current offset: use drag offset during drag, animatable otherwise
|
// Current offset: use drag offset during drag, animatable otherwise
|
||||||
val currentOffset = if (isDragging) dragOffset else offsetAnimatable.value
|
val currentOffset = if (isDragging) dragOffset else offsetAnimatable.value
|
||||||
|
|
||||||
@@ -159,6 +169,10 @@ fun SwipeBackContainer(
|
|||||||
startedSwipe = true
|
startedSwipe = true
|
||||||
isDragging = true
|
isDragging = true
|
||||||
dragOffset = offsetAnimatable.value
|
dragOffset = offsetAnimatable.value
|
||||||
|
// 🔥 Скрываем клавиатуру при начале свайпа (надёжный метод)
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
|
focusManager.clearFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startedSwipe) {
|
if (startedSwipe) {
|
||||||
|
|||||||
@@ -443,10 +443,11 @@ fun ProfileMetaballOverlay(
|
|||||||
|
|
||||||
// Show connector only when avatar is small enough (like Telegram isDrawing)
|
// Show connector only when avatar is small enough (like Telegram isDrawing)
|
||||||
// AND not when expanding (no metaball effect when expanded)
|
// AND not when expanding (no metaball effect when expanded)
|
||||||
val showConnector = expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f
|
// AND only when hasAvatar is true (no drop animation for placeholder)
|
||||||
|
val showConnector = hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f
|
||||||
|
|
||||||
// Don't show black metaball shapes when expanded
|
// Don't show black metaball shapes when expanded or when no avatar
|
||||||
val showMetaballLayer = expansionProgress == 0f
|
val showMetaballLayer = hasAvatar && expansionProgress == 0f
|
||||||
|
|
||||||
Box(modifier = modifier
|
Box(modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|||||||
273
app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt
Normal file
273
app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
package com.rosetta.messenger.utils
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Утилита для логирования сообщений
|
||||||
|
* Безопасна для release сборки - логи не выводятся в production
|
||||||
|
*
|
||||||
|
* Логи отображаются:
|
||||||
|
* 1. В Logcat (всегда в debug)
|
||||||
|
* 2. В Debug Logs внутри чата (через ProtocolManager.debugLogs)
|
||||||
|
*/
|
||||||
|
object MessageLogger {
|
||||||
|
private const val TAG = "RosettaMsg"
|
||||||
|
|
||||||
|
// Включить/выключить логирование (только в DEBUG)
|
||||||
|
private val isEnabled: Boolean = android.os.Build.TYPE != "user"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавить лог в UI (Debug Logs в чате)
|
||||||
|
*/
|
||||||
|
private fun addToUI(message: String) {
|
||||||
|
ProtocolManager.addLog(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование отправки сообщения
|
||||||
|
*/
|
||||||
|
fun logSendStart(
|
||||||
|
messageId: String,
|
||||||
|
toPublicKey: String,
|
||||||
|
textLength: Int,
|
||||||
|
attachmentsCount: Int,
|
||||||
|
isSavedMessages: Boolean,
|
||||||
|
replyToMessageId: String?
|
||||||
|
) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortId = messageId.take(8)
|
||||||
|
val shortKey = toPublicKey.take(12)
|
||||||
|
val msg = "📤 SEND | id:\$shortId to:\$shortKey len:\$textLength att:\$attachmentsCount saved:\$isSavedMessages reply:\${replyToMessageId?.take(8) ?: \"-\"}"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование успешного шифрования
|
||||||
|
*/
|
||||||
|
fun logEncryptionSuccess(
|
||||||
|
messageId: String,
|
||||||
|
encryptedContentLength: Int,
|
||||||
|
encryptedKeyLength: Int
|
||||||
|
) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortId = messageId.take(8)
|
||||||
|
val msg = "🔐 ENCRYPT | id:\$shortId content:\${encryptedContentLength}b key:\${encryptedKeyLength}b"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование сохранения в БД
|
||||||
|
*/
|
||||||
|
fun logDbSave(
|
||||||
|
messageId: String,
|
||||||
|
dialogKey: String,
|
||||||
|
isNew: Boolean
|
||||||
|
) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortId = messageId.take(8)
|
||||||
|
val shortDialog = dialogKey.take(12)
|
||||||
|
val msg = "💾 DB | id:\$shortId dialog:\$shortDialog new:\$isNew"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование отправки пакета на сервер
|
||||||
|
*/
|
||||||
|
fun logPacketSend(
|
||||||
|
messageId: String,
|
||||||
|
toPublicKey: String,
|
||||||
|
timestamp: Long
|
||||||
|
) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortId = messageId.take(8)
|
||||||
|
val shortKey = toPublicKey.take(12)
|
||||||
|
val msg = "📡 PACKET→ | id:\$shortId to:\$shortKey ts:\$timestamp"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование успешной отправки
|
||||||
|
*/
|
||||||
|
fun logSendSuccess(messageId: String, duration: Long) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortId = messageId.take(8)
|
||||||
|
val msg = "✅ SENT | id:\$shortId time:\${duration}ms"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование ошибки отправки
|
||||||
|
*/
|
||||||
|
fun logSendError(messageId: String, error: Throwable) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortId = messageId.take(8)
|
||||||
|
val msg = "❌ SEND ERR | id:\$shortId err:\${error.message?.take(50)}"
|
||||||
|
Log.e(TAG, msg, error)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование входящего сообщения
|
||||||
|
*/
|
||||||
|
fun logReceiveStart(
|
||||||
|
messageId: String,
|
||||||
|
fromPublicKey: String,
|
||||||
|
toPublicKey: String,
|
||||||
|
contentLength: Int,
|
||||||
|
attachmentsCount: Int,
|
||||||
|
timestamp: Long
|
||||||
|
) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortId = messageId.take(8)
|
||||||
|
val shortFrom = fromPublicKey.take(12)
|
||||||
|
val shortTo = toPublicKey.take(12)
|
||||||
|
val msg = "📥 RECV | id:\$shortId from:\$shortFrom to:\$shortTo len:\${contentLength}b att:\$attachmentsCount ts:\$timestamp"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование проверки на дубликат
|
||||||
|
*/
|
||||||
|
fun logDuplicateCheck(messageId: String, isDuplicate: Boolean) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortId = messageId.take(8)
|
||||||
|
val status = if (isDuplicate) "⚠️DUP" else "✓NEW"
|
||||||
|
val msg = "🔍 CHECK | id:\$shortId \$status"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование блокировки отправителя
|
||||||
|
*/
|
||||||
|
fun logBlockedSender(fromPublicKey: String) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortKey = fromPublicKey.take(12)
|
||||||
|
val msg = "🚫 BLOCKED | from:\$shortKey"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование успешной расшифровки
|
||||||
|
*/
|
||||||
|
fun logDecryptionSuccess(
|
||||||
|
messageId: String,
|
||||||
|
plainTextLength: Int,
|
||||||
|
attachmentsCount: Int
|
||||||
|
) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortId = messageId.take(8)
|
||||||
|
val msg = "🔓 DECRYPT | id:\$shortId text:\${plainTextLength}c att:\$attachmentsCount"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование ошибки расшифровки
|
||||||
|
*/
|
||||||
|
fun logDecryptionError(messageId: String, error: Throwable) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortId = messageId.take(8)
|
||||||
|
val msg = "❌ DECRYPT ERR | id:\$shortId err:\${error.message?.take(50)}"
|
||||||
|
Log.e(TAG, msg, error)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование успешного получения
|
||||||
|
*/
|
||||||
|
fun logReceiveSuccess(messageId: String, duration: Long) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortId = messageId.take(8)
|
||||||
|
val msg = "✅ RECEIVED | id:\$shortId time:\${duration}ms"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование обновления статуса доставки
|
||||||
|
*/
|
||||||
|
fun logDeliveryStatus(
|
||||||
|
messageId: String,
|
||||||
|
toPublicKey: String,
|
||||||
|
status: String
|
||||||
|
) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortId = messageId.take(8)
|
||||||
|
val shortKey = toPublicKey.take(12)
|
||||||
|
val msg = "📬 DELIVERY | id:\$shortId to:\$shortKey status:\$status"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование прочтения сообщений
|
||||||
|
*/
|
||||||
|
fun logReadStatus(
|
||||||
|
fromPublicKey: String,
|
||||||
|
messagesCount: Int
|
||||||
|
) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortKey = fromPublicKey.take(12)
|
||||||
|
val msg = "👁 READ | from:\$shortKey count:\$messagesCount"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование обновления диалога
|
||||||
|
*/
|
||||||
|
fun logDialogUpdate(
|
||||||
|
dialogKey: String,
|
||||||
|
lastMessage: String?,
|
||||||
|
unreadCount: Int
|
||||||
|
) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortDialog = dialogKey.take(12)
|
||||||
|
val shortMsg = lastMessage?.take(20) ?: "-"
|
||||||
|
val msg = "📋 DIALOG | key:\$shortDialog last:\"\$shortMsg\" unread:\$unreadCount"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование обновления кэша
|
||||||
|
*/
|
||||||
|
fun logCacheUpdate(dialogKey: String, totalMessages: Int) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortDialog = dialogKey.take(12)
|
||||||
|
val msg = "🗃 CACHE | dialog:\$shortDialog total:\$totalMessages"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общий debug лог
|
||||||
|
*/
|
||||||
|
fun debug(message: String) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
Log.d(TAG, message)
|
||||||
|
addToUI(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общий error лог
|
||||||
|
*/
|
||||||
|
fun error(message: String, error: Throwable? = null) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
if (error != null) {
|
||||||
|
Log.e(TAG, message, error)
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, message)
|
||||||
|
}
|
||||||
|
addToUI("❌ \$message")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.rosetta.messenger.utils
|
||||||
|
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Глобальный менеджер throttle для отправки сообщений
|
||||||
|
* Предотвращает дублирование при быстром нажатии на кнопку отправки
|
||||||
|
*
|
||||||
|
* 🔥 КРИТИЧНО: Singleton обеспечивает app-wide throttle,
|
||||||
|
* в отличие от per-ViewModel throttle который обходится при навигации между чатами
|
||||||
|
*/
|
||||||
|
object MessageThrottleManager {
|
||||||
|
|
||||||
|
// Время последней отправки для каждого диалога
|
||||||
|
private val sendTimes = ConcurrentHashMap<String, Long>()
|
||||||
|
|
||||||
|
// Минимальный интервал между отправками в одном диалоге (мс)
|
||||||
|
private const val THROTTLE_MS = 100L
|
||||||
|
|
||||||
|
// Дополнительный throttle для повторной отправки одинакового контента
|
||||||
|
private val lastContentHashes = ConcurrentHashMap<String, Int>()
|
||||||
|
private const val CONTENT_THROTTLE_MS = 500L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, можно ли отправить сообщение в указанный диалог
|
||||||
|
* @param dialogKey уникальный ключ диалога
|
||||||
|
* @return true если отправка разрешена, false если нужно подождать
|
||||||
|
*/
|
||||||
|
fun canSend(dialogKey: String): Boolean {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val lastTime = sendTimes[dialogKey] ?: 0L
|
||||||
|
|
||||||
|
if (now - lastTime < THROTTLE_MS) {
|
||||||
|
MessageLogger.debug("⏱️ THROTTLE: Message blocked for $dialogKey (${now - lastTime}ms < ${THROTTLE_MS}ms)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTimes[dialogKey] = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет throttle с учётом содержимого сообщения
|
||||||
|
* Предотвращает отправку идентичных сообщений подряд
|
||||||
|
*
|
||||||
|
* @param dialogKey уникальный ключ диалога
|
||||||
|
* @param contentHash хэш содержимого сообщения (text.hashCode())
|
||||||
|
* @return true если отправка разрешена
|
||||||
|
*/
|
||||||
|
fun canSendWithContent(dialogKey: String, contentHash: Int): Boolean {
|
||||||
|
if (!canSend(dialogKey)) return false
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val key = "$dialogKey:$contentHash"
|
||||||
|
val lastTime = sendTimes[key] ?: 0L
|
||||||
|
|
||||||
|
// Блокируем отправку идентичного контента в течение CONTENT_THROTTLE_MS
|
||||||
|
if (now - lastTime < CONTENT_THROTTLE_MS) {
|
||||||
|
MessageLogger.debug("⏱️ CONTENT THROTTLE: Duplicate content blocked for $dialogKey")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTimes[key] = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очистка всех throttle данных (вызывается при logout)
|
||||||
|
*/
|
||||||
|
fun clear() {
|
||||||
|
sendTimes.clear()
|
||||||
|
lastContentHashes.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user