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.utils.AttachmentFileManager
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import com.rosetta.messenger.utils.MessageLogger
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.json.JSONArray
|
||||
@@ -85,6 +86,28 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
@Volatile
|
||||
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 {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: MessageRepository(context.applicationContext).also { INSTANCE = it }
|
||||
@@ -175,6 +198,16 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// 📁 Проверяем является ли это Saved Messages
|
||||
val isSavedMessages = (account == toPublicKey)
|
||||
|
||||
// 📝 LOG: Начало отправки
|
||||
MessageLogger.logSendStart(
|
||||
messageId = messageId,
|
||||
toPublicKey = toPublicKey,
|
||||
textLength = text.trim().length,
|
||||
attachmentsCount = attachments.size,
|
||||
isSavedMessages = isSavedMessages,
|
||||
replyToMessageId = replyToMessageId
|
||||
)
|
||||
|
||||
// 1. Создаем оптимистичное сообщение
|
||||
// 📁 Для saved messages - сразу DELIVERED и прочитано
|
||||
val optimisticMessage = Message(
|
||||
@@ -192,9 +225,11 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
// 2. Обновляем UI сразу (Optimistic Update)
|
||||
updateMessageCache(dialogKey, optimisticMessage)
|
||||
MessageLogger.logCacheUpdate(dialogKey, messageCache[dialogKey]?.value?.size ?: 0)
|
||||
|
||||
// 3. Фоновая обработка
|
||||
scope.launch {
|
||||
val startTime = System.currentTimeMillis()
|
||||
try {
|
||||
// Шифрование
|
||||
val encryptResult = MessageCrypto.encryptForSending(
|
||||
@@ -204,6 +239,13 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
|
||||
// 📝 LOG: Шифрование успешно
|
||||
MessageLogger.logEncryptionSuccess(
|
||||
messageId = messageId,
|
||||
encryptedContentLength = encryptedContent.length,
|
||||
encryptedKeyLength = encryptedKey.length
|
||||
)
|
||||
|
||||
// 🔑 КРИТИЧНО: Сохраняем ЗАШИФРОВАННЫЙ chacha_key (как в Desktop!)
|
||||
// Desktop хранит зашифрованный ключ, расшифровывает только при использовании
|
||||
|
||||
@@ -236,6 +278,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
)
|
||||
messageDao.insertMessage(entity)
|
||||
|
||||
// 📝 LOG: Сохранено в БД
|
||||
MessageLogger.logDbSave(messageId, dialogKey, isNew = true)
|
||||
} else {
|
||||
MessageLogger.logDbSave(messageId, dialogKey, isNew = false)
|
||||
}
|
||||
|
||||
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
|
||||
@@ -243,6 +289,11 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
// 🔥 Логируем что записалось в диалог
|
||||
val dialog = dialogDao.getDialog(account, toPublicKey)
|
||||
MessageLogger.logDialogUpdate(
|
||||
dialogKey = dialogKey,
|
||||
lastMessage = dialog?.lastMessage,
|
||||
unreadCount = dialog?.unreadCount ?: 0
|
||||
)
|
||||
|
||||
// 🔥 Отмечаем что я отправлял сообщения в этот диалог (перемещает из requests в chats)
|
||||
val updatedRows = dialogDao.markIHaveSent(account, toPublicKey)
|
||||
@@ -250,6 +301,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// 📁 НЕ отправляем пакет на сервер для saved messages!
|
||||
// Как в Архиве: if(publicKey == opponentPublicKey) return;
|
||||
if (isSavedMessages) {
|
||||
MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
|
||||
MessageLogger.debug("📁 SavedMessages: skipping server send")
|
||||
return@launch // Для saved messages - только локальное сохранение, без отправки на сервер
|
||||
}
|
||||
|
||||
@@ -265,9 +318,18 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
this.attachments = attachments
|
||||
}
|
||||
|
||||
// 📝 LOG: Отправка пакета
|
||||
MessageLogger.logPacketSend(messageId, toPublicKey, timestamp)
|
||||
|
||||
ProtocolManager.send(packet)
|
||||
|
||||
// 📝 LOG: Успешная отправка
|
||||
MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
|
||||
|
||||
} catch (e: Exception) {
|
||||
// 📝 LOG: Ошибка отправки
|
||||
MessageLogger.logSendError(messageId, e)
|
||||
|
||||
// При ошибке обновляем статус
|
||||
messageDao.updateDeliveryStatus(account, messageId, DeliveryStatus.ERROR.value)
|
||||
updateMessageStatus(dialogKey, messageId, DeliveryStatus.ERROR)
|
||||
@@ -281,29 +343,53 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
* Обработка входящего сообщения
|
||||
*/
|
||||
suspend fun handleIncomingMessage(packet: PacketMessage) {
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
val account = currentAccount ?: run {
|
||||
MessageLogger.debug("📥 RECEIVE SKIP: account is null")
|
||||
return
|
||||
}
|
||||
val privateKey = currentPrivateKey ?: run {
|
||||
MessageLogger.debug("📥 RECEIVE SKIP: privateKey is null")
|
||||
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)
|
||||
if (isBlocked) {
|
||||
MessageLogger.logBlockedSender(packet.fromPublicKey)
|
||||
return
|
||||
}
|
||||
|
||||
// 🔥 Генерируем messageId если он пустой (как в Архиве - generateRandomKeyFormSeed)
|
||||
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 {
|
||||
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)
|
||||
MessageLogger.logDuplicateCheck(messageId, isDuplicate)
|
||||
if (isDuplicate) {
|
||||
return
|
||||
}
|
||||
@@ -322,6 +408,13 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
privateKey
|
||||
)
|
||||
|
||||
// 📝 LOG: Расшифровка успешна
|
||||
MessageLogger.logDecryptionSuccess(
|
||||
messageId = messageId,
|
||||
plainTextLength = plainText.length,
|
||||
attachmentsCount = packet.attachments.size
|
||||
)
|
||||
|
||||
// Сериализуем attachments в JSON с расшифровкой MESSAGES blob
|
||||
val attachmentsJson = serializeAttachmentsWithDecryption(
|
||||
packet.attachments,
|
||||
@@ -335,7 +428,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
|
||||
processAvatarAttachments(packet.attachments, packet.fromPublicKey, packet.chachaKey, privateKey)
|
||||
|
||||
// <EFBFBD>🔒 Шифруем plainMessage с использованием приватного ключа
|
||||
// 🔒 Шифруем plainMessage с использованием приватного ключа
|
||||
val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey)
|
||||
|
||||
// Создаем entity для кэша и возможной вставки
|
||||
@@ -361,7 +454,9 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
if (!stillExists) {
|
||||
// Сохраняем в БД только если сообщения нет
|
||||
messageDao.insertMessage(entity)
|
||||
|
||||
MessageLogger.logDbSave(messageId, dialogKey, isNew = true)
|
||||
} else {
|
||||
MessageLogger.logDbSave(messageId, dialogKey, isNew = false)
|
||||
}
|
||||
|
||||
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
|
||||
@@ -369,6 +464,11 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
// 🔥 Логируем что записалось в диалог
|
||||
val dialog = dialogDao.getDialog(account, packet.fromPublicKey)
|
||||
MessageLogger.logDialogUpdate(
|
||||
dialogKey = dialogKey,
|
||||
lastMessage = dialog?.lastMessage,
|
||||
unreadCount = dialog?.unreadCount ?: 0
|
||||
)
|
||||
|
||||
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа
|
||||
requestUserInfo(packet.fromPublicKey)
|
||||
@@ -377,12 +477,18 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
if (!stillExists) {
|
||||
val message = entity.toMessage()
|
||||
updateMessageCache(dialogKey, message)
|
||||
MessageLogger.logCacheUpdate(dialogKey, messageCache[dialogKey]?.value?.size ?: 0)
|
||||
|
||||
// 🔔 Уведомляем UI о новом сообщении (emit dialogKey для обновления)
|
||||
_newMessageEvents.tryEmit(dialogKey)
|
||||
}
|
||||
|
||||
// 📝 LOG: Успешная обработка
|
||||
MessageLogger.logReceiveSuccess(messageId, System.currentTimeMillis() - startTime)
|
||||
|
||||
} catch (e: Exception) {
|
||||
// 📝 LOG: Ошибка обработки
|
||||
MessageLogger.logDecryptionError(messageId, e)
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
@@ -392,6 +498,14 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
*/
|
||||
suspend fun handleDelivery(packet: PacketDelivery) {
|
||||
val account = currentAccount ?: return
|
||||
|
||||
// 📝 LOG: Получено подтверждение доставки
|
||||
MessageLogger.logDeliveryStatus(
|
||||
messageId = packet.messageId,
|
||||
toPublicKey = packet.toPublicKey,
|
||||
status = "DELIVERED"
|
||||
)
|
||||
|
||||
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) {
|
||||
val account = currentAccount ?: return
|
||||
|
||||
MessageLogger.debug("👁 READ PACKET from: ${packet.fromPublicKey.take(16)}...")
|
||||
|
||||
// Проверяем последнее сообщение ДО обновления
|
||||
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 readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0
|
||||
messageCache[dialogKey]?.let { flow ->
|
||||
flow.value = flow.value.map { msg ->
|
||||
if (msg.isFromMe && !msg.isRead) msg.copy(isRead = true)
|
||||
@@ -429,6 +545,12 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 📝 LOG: Статус прочтения
|
||||
MessageLogger.logReadStatus(
|
||||
fromPublicKey = packet.fromPublicKey,
|
||||
messagesCount = readCount
|
||||
)
|
||||
|
||||
// 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageRead обновился
|
||||
dialogDao.updateDialogFromMessages(account, packet.fromPublicKey)
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.chats.models.*
|
||||
@@ -283,6 +284,15 @@ fun ChatDetailScreen(
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
var showBlockConfirm 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
|
||||
val isBlocked by database.blacklistDao()
|
||||
@@ -864,20 +874,20 @@ fun ChatDetailScreen(
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(40.dp)
|
||||
.clickable(
|
||||
indication =
|
||||
null,
|
||||
interactionSource =
|
||||
remember {
|
||||
MutableInteractionSource()
|
||||
}
|
||||
.then(
|
||||
if (!isSavedMessages) {
|
||||
Modifier.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
// Мгновенное закрытие клавиатуры через нативный API
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus()
|
||||
onUserProfileClick(user)
|
||||
},
|
||||
}
|
||||
} else Modifier
|
||||
),
|
||||
contentAlignment =
|
||||
Alignment.Center
|
||||
) {
|
||||
@@ -924,13 +934,11 @@ fun ChatDetailScreen(
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.weight(1f)
|
||||
.clickable(
|
||||
indication =
|
||||
null,
|
||||
interactionSource =
|
||||
remember {
|
||||
MutableInteractionSource()
|
||||
}
|
||||
.then(
|
||||
if (!isSavedMessages) {
|
||||
Modifier.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
// Мгновенное закрытие клавиатуры через нативный API
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
@@ -938,6 +946,8 @@ fun ChatDetailScreen(
|
||||
focusManager.clearFocus()
|
||||
onUserProfileClick(user)
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment =
|
||||
@@ -1093,6 +1103,12 @@ fun ChatDetailScreen(
|
||||
false
|
||||
showDeleteConfirm =
|
||||
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
|
||||
if (showForwardPicker) {
|
||||
ForwardChatPickerBottomSheet(
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.*
|
||||
import com.rosetta.messenger.ui.chats.models.*
|
||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import com.rosetta.messenger.utils.MessageThrottleManager
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.json.JSONArray
|
||||
@@ -146,10 +147,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// Защита от двойной отправки
|
||||
private var isSending = false
|
||||
|
||||
// 🔥 Throttling для отправки сообщений (защита от спама)
|
||||
private var lastMessageSentTime = 0L
|
||||
private val MESSAGE_THROTTLE_MS = 100L // Минимум 100ms между сообщениями
|
||||
// 🔥 Throttling перенесён в глобальный MessageThrottleManager
|
||||
|
||||
// Job для отмены загрузки при смене диалога
|
||||
private var loadingJob: Job? = null
|
||||
@@ -170,6 +168,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// 🟢 Флаг что уже подписаны на онлайн статус собеседника
|
||||
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 {
|
||||
setupPacketListeners()
|
||||
setupNewMessageListener()
|
||||
@@ -301,67 +317,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
private fun setupPacketListeners() {
|
||||
// ✅ Входящие сообщения обрабатываются ТОЛЬКО в MessageRepository (ProtocolManager)
|
||||
// Здесь НЕ обрабатываем 0x06 чтобы избежать дублирования!
|
||||
// ChatViewModel получает обновления через loadMessagesFromDatabase() после сохранения в БД
|
||||
// ✅ Обработчики 0x06, 0x07, 0x08 удалены - они обрабатываются ТОЛЬКО в ProtocolManager
|
||||
// Это предотвращает дублирование сообщений и статусов при навигации между чатами
|
||||
// ChatViewModel получает обновления через messageCache Flow из MessageRepository
|
||||
|
||||
// Доставка
|
||||
ProtocolManager.waitPacket(0x08) { packet ->
|
||||
val deliveryPacket = packet as PacketDelivery
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Обновляем в БД
|
||||
updateMessageStatusInDb(deliveryPacket.messageId, 1) // DELIVERED
|
||||
// Обновляем UI
|
||||
withContext(Dispatchers.Main) {
|
||||
updateMessageStatus(deliveryPacket.messageId, MessageStatus.DELIVERED)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Typing - нужен здесь для UI текущего чата
|
||||
ProtocolManager.waitPacket(0x0B, typingPacketHandler)
|
||||
|
||||
// Прочитано - пакет сообщает что собеседник прочитал наши сообщения
|
||||
// В Desktop нет messageId - просто отмечаем все исходящие сообщения как прочитанные
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
// 🟢 Онлайн статус - нужен здесь для UI текущего чата
|
||||
ProtocolManager.waitPacket(0x05, onlinePacketHandler)
|
||||
}
|
||||
|
||||
// ✅ handleIncomingMessage удалён - обработка входящих сообщений теперь ТОЛЬКО в MessageRepository
|
||||
@@ -1318,12 +1282,11 @@ val newList = messages + optimisticMessages
|
||||
return
|
||||
}
|
||||
|
||||
// 🔥 Throttling - защита от спама сообщениями
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastMessageSentTime < MESSAGE_THROTTLE_MS) {
|
||||
// 🔥 Глобальный throttle - защита от спама сообщениями (app-wide)
|
||||
val dialogKey = "$sender:$recipient"
|
||||
if (!MessageThrottleManager.canSendWithContent(dialogKey, text.hashCode())) {
|
||||
return
|
||||
}
|
||||
lastMessageSentTime = now
|
||||
|
||||
isSending = true
|
||||
|
||||
@@ -2788,6 +2751,11 @@ val newList = messages + optimisticMessages
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
|
||||
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
|
||||
ProtocolManager.unwaitPacket(0x0B, typingPacketHandler)
|
||||
ProtocolManager.unwaitPacket(0x05, onlinePacketHandler)
|
||||
|
||||
lastReadMessageTimestamp = 0L
|
||||
readReceiptSentForCurrentDialog = false
|
||||
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке
|
||||
|
||||
@@ -1162,7 +1162,8 @@ fun KebabMenu(
|
||||
isBlocked: Boolean,
|
||||
onBlockClick: () -> Unit,
|
||||
onUnblockClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
onDeleteClick: () -> Unit,
|
||||
onLogsClick: () -> Unit = {}
|
||||
) {
|
||||
val dividerColor =
|
||||
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(
|
||||
icon = TablerIcons.Trash,
|
||||
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.util.VelocityTracker
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import android.content.Context
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
|
||||
@@ -69,6 +74,11 @@ fun SwipeBackContainer(
|
||||
// Coroutine scope for animations
|
||||
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
|
||||
val currentOffset = if (isDragging) dragOffset else offsetAnimatable.value
|
||||
|
||||
@@ -159,6 +169,10 @@ fun SwipeBackContainer(
|
||||
startedSwipe = true
|
||||
isDragging = true
|
||||
dragOffset = offsetAnimatable.value
|
||||
// 🔥 Скрываем клавиатуру при начале свайпа (надёжный метод)
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
|
||||
if (startedSwipe) {
|
||||
|
||||
@@ -443,10 +443,11 @@ fun ProfileMetaballOverlay(
|
||||
|
||||
// Show connector only when avatar is small enough (like Telegram isDrawing)
|
||||
// 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
|
||||
val showMetaballLayer = expansionProgress == 0f
|
||||
// Don't show black metaball shapes when expanded or when no avatar
|
||||
val showMetaballLayer = hasAvatar && expansionProgress == 0f
|
||||
|
||||
Box(modifier = modifier
|
||||
.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