feat: implement debug logging functionality and UI for message processing

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

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)
}
) {
// Мгновенное закрытие клавиатуры через нативный 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(

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

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