From c455994224cfd9602f5a4a47814fef77de99df1e Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 6 Feb 2026 00:21:11 +0500 Subject: [PATCH] feat: implement debug logging functionality and UI for message processing --- .../messenger/data/MessageRepository.kt | 134 ++++++++- .../messenger/ui/chats/ChatDetailScreen.kt | 78 +++-- .../messenger/ui/chats/ChatViewModel.kt | 110 +++---- .../chats/components/ChatDetailComponents.kt | 20 +- .../chats/components/DebugLogsBottomSheet.kt | 258 +++++++++++++++++ .../ui/components/SwipeBackContainer.kt | 14 + .../metaball/ProfileMetaballOverlay.kt | 7 +- .../rosetta/messenger/utils/MessageLogger.kt | 273 ++++++++++++++++++ .../messenger/utils/MessageThrottleManager.kt | 74 +++++ 9 files changed, 861 insertions(+), 107 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/components/DebugLogsBottomSheet.kt create mode 100644 app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt create mode 100644 app/src/main/java/com/rosetta/messenger/utils/MessageThrottleManager.kt diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 60cff14..7e5482c 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -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 @@ -84,7 +85,29 @@ class MessageRepository private constructor(private val context: Context) { companion object { @Volatile private var INSTANCE: MessageRepository? = null - + + // 🔥 In-memory кэш обработанных messageId для предотвращения дубликатов + // LRU кэш с ограничением 1000 элементов - защита от race conditions + private val processedMessageIds = java.util.Collections.synchronizedSet( + object : LinkedHashSet() { + 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) - // �🔒 Шифруем 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) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 78599e1..06400d8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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) } - ) { - // Мгновенное закрытие клавиатуры через нативный 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,20 +934,20 @@ 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 + imm.hideSoftInputFromWindow(view.windowToken, 0) + focusManager.clearFocus() + onUserProfileClick(user) } - ) { - // Мгновенное закрытие клавиатуры через нативный API - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - 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( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 446195f..ff6c264 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -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,11 +147,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Защита от двойной отправки private var isSending = false + // 🔥 Throttling перенесён в глобальный MessageThrottleManager - // 🔥 Throttling для отправки сообщений (защита от спама) - private var lastMessageSentTime = 0L - private val MESSAGE_THROTTLE_MS = 100L // Минимум 100ms между сообщениями - // Job для отмены загрузки при смене диалога private var loadingJob: Job? = null @@ -169,7 +167,25 @@ 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() @@ -299,69 +315,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } } - + private fun setupPacketListeners() { - // ✅ Входящие сообщения обрабатываются ТОЛЬКО в MessageRepository (ProtocolManager) - // Здесь НЕ обрабатываем 0x06 чтобы избежать дублирования! - // ChatViewModel получает обновления через loadMessagesFromDatabase() после сохранения в БД - - // Доставка - 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) - } - } - } - - // Прочитано - пакет сообщает что собеседник прочитал наши сообщения - // В 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 - } - } - } + // ✅ Обработчики 0x06, 0x07, 0x08 удалены - они обрабатываются ТОЛЬКО в ProtocolManager + // Это предотвращает дублирование сообщений и статусов при навигации между чатами + // ChatViewModel получает обновления через messageCache Flow из MessageRepository + + // Typing - нужен здесь для UI текущего чата + ProtocolManager.waitPacket(0x0B, typingPacketHandler) + + // 🟢 Онлайн статус - нужен здесь для 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 // 🔥 Сбрасываем флаг при очистке diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 74ad5ca..6e00e3a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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", diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/DebugLogsBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/DebugLogsBottomSheet.kt new file mode 100644 index 0000000..caafc91 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/DebugLogsBottomSheet.kt @@ -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, + 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) + ) +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt index 63c3809..d2df8ad 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt @@ -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) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt index d7be721..e9cf1e3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt @@ -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() diff --git a/app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt b/app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt new file mode 100644 index 0000000..6efe67e --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt @@ -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") + } +} diff --git a/app/src/main/java/com/rosetta/messenger/utils/MessageThrottleManager.kt b/app/src/main/java/com/rosetta/messenger/utils/MessageThrottleManager.kt new file mode 100644 index 0000000..ab7a8fe --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/utils/MessageThrottleManager.kt @@ -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() + + // Минимальный интервал между отправками в одном диалоге (мс) + private const val THROTTLE_MS = 100L + + // Дополнительный throttle для повторной отправки одинакового контента + private val lastContentHashes = ConcurrentHashMap() + 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() + } +}