feat: implement debug logging functionality and UI for message processing

This commit is contained in:
k1ngsterr1
2026-02-06 00:21:11 +05:00
parent 718eb4ef56
commit c455994224
9 changed files with 861 additions and 107 deletions

View File

@@ -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)

View File

@@ -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(

View File

@@ -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 // 🔥 Сбрасываем флаг при очистке

View File

@@ -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",

View File

@@ -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)
)
}

View File

@@ -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) {

View File

@@ -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()

View 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")
}
}

View File

@@ -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()
}
}