From 52ffc2276320baa08ccfa58d8706255cbd206e21 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 17 Jan 2026 05:53:27 +0500 Subject: [PATCH] feat: Implement FCM token handling and dialog cache management; enhance user experience and performance --- .../com/rosetta/messenger/MainActivity.kt | 36 ++++-- .../messenger/data/MessageRepository.kt | 17 +++ .../messenger/database/MessageEntities.kt | 22 +++- .../com/rosetta/messenger/network/Packets.kt | 40 +++++- .../push/RosettaFirebaseMessagingService.kt | 28 +---- .../messenger/ui/chats/ChatDetailScreen.kt | 34 ++++- .../messenger/ui/chats/ChatViewModel.kt | 30 ++++- .../messenger/ui/chats/ChatsListScreen.kt | 119 ++++++++++++++++-- .../messenger/ui/chats/ChatsListViewModel.kt | 72 ++++++++++- 9 files changed, 333 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index d3a19cb..88d3ebf 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -43,8 +43,10 @@ import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.RecentSearchesManager -import com.rosetta.messenger.network.PacketPushToken +import com.rosetta.messenger.network.PacketPushNotification +import com.rosetta.messenger.network.PushNotificationAction import com.rosetta.messenger.network.ProtocolManager +import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.ui.auth.AccountInfo import com.rosetta.messenger.ui.auth.AuthFlow import com.rosetta.messenger.ui.chats.ChatsListScreen @@ -57,6 +59,7 @@ import com.rosetta.messenger.ui.onboarding.OnboardingScreen import com.rosetta.messenger.ui.splash.SplashScreen import com.rosetta.messenger.ui.theme.RosettaAndroidTheme import kotlinx.coroutines.launch +import kotlinx.coroutines.delay class MainActivity : ComponentActivity() { private lateinit var preferencesManager: PreferencesManager @@ -331,19 +334,32 @@ class MainActivity : ComponentActivity() { return@launch } - Log.d(TAG, "📤 Sending FCM token to server...") - Log.d(TAG, " Token (short): ${token.take(20)}...") - Log.d(TAG, " PublicKey: ${account.publicKey.take(20)}...") + // 🔥 КРИТИЧНО: Ждем пока протокол станет AUTHENTICATED + var waitAttempts = 0 + while (ProtocolManager.state.value != ProtocolState.AUTHENTICATED && waitAttempts < 50) { + Log.d(TAG, "⏳ Waiting for protocol to be authenticated... (attempt ${waitAttempts + 1}/50)") + delay(100) // Ждем 100ms + waitAttempts++ + } - val packet = PacketPushToken().apply { - this.privateKey = account.privateKey - this.publicKey = account.publicKey - this.pushToken = token - this.platform = "android" + if (ProtocolManager.state.value != ProtocolState.AUTHENTICATED) { + Log.e(TAG, "❌ Cannot send FCM token: Protocol not authenticated after 5 seconds") + return@launch + } + + Log.d(TAG, "📤 Sending FCM token to server (new format 0x10)...") + Log.d(TAG, " Token (short): ${token.take(20)}...") + Log.d(TAG, " Token (FULL): $token") + Log.d(TAG, " Action: SUBSCRIBE") + Log.d(TAG, " Protocol state: ${ProtocolManager.state.value}") + + val packet = PacketPushNotification().apply { + this.notificationsToken = token + this.action = PushNotificationAction.SUBSCRIBE } ProtocolManager.send(packet) - Log.d(TAG, "✅ FCM token sent to server") + Log.d(TAG, "✅ FCM token sent to server (packet ID: 0x10)") } catch (e: Exception) { Log.e(TAG, "❌ Error sending FCM token to server", e) } 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 2b32e4c..7807195 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -551,6 +551,23 @@ class MessageRepository private constructor(private val context: Context) { } } + /** + * Очистить кэш сообщений для конкретного диалога + * 🔥 ВАЖНО: Устанавливаем пустой список, а не просто удаляем - + * чтобы подписчики Flow увидели что диалог пуст + */ + fun clearDialogCache(opponentKey: String) { + val dialogKey = getDialogKey(opponentKey) + android.util.Log.d("MessageRepo", "🗑️ clearDialogCache: dialogKey=$dialogKey") + + // Сначала устанавливаем пустой список чтобы все подписчики увидели + messageCache[dialogKey]?.value = emptyList() + + // Затем удаляем из кэша + messageCache.remove(dialogKey) + android.util.Log.d("MessageRepo", "🗑️ Cache cleared for dialogKey=$dialogKey") + } + /** * Запросить информацию о пользователе с сервера */ diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index e423f76..2838fc2 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -236,13 +236,13 @@ interface MessageDao { suspend fun getUnreadCountForDialog(account: String, opponentKey: String): Int /** - * Удалить все сообщения диалога + * Удалить все сообщения диалога (возвращает количество удалённых) */ @Query("DELETE FROM messages WHERE account = :account AND dialog_key = :dialogKey") - suspend fun deleteDialog(account: String, dialogKey: String) + suspend fun deleteDialog(account: String, dialogKey: String): Int /** - * Удалить все сообщения между двумя пользователями + * Удалить все сообщения между двумя пользователями (возвращает количество удалённых) */ @Query(""" DELETE FROM messages @@ -251,7 +251,7 @@ interface MessageDao { (from_public_key = :user2 AND to_public_key = :user1) ) """) - suspend fun deleteMessagesBetweenUsers(account: String, user1: String, user2: String) + suspend fun deleteMessagesBetweenUsers(account: String, user1: String, user2: String): Int /** * Количество непрочитанных сообщений в диалоге @@ -294,33 +294,39 @@ interface DialogDao { /** * Получить все диалоги отсортированные по последнему сообщению * Исключает requests (диалоги без исходящих сообщений от нас) + * Исключает пустые диалоги (без сообщений) */ @Query(""" SELECT * FROM dialogs WHERE account = :account AND i_have_sent = 1 + AND last_message_timestamp > 0 ORDER BY last_message_timestamp DESC """) fun getDialogsFlow(account: String): Flow> /** * Получить requests - диалоги где нам писали, но мы не отвечали + * Исключает пустые диалоги (без сообщений) */ @Query(""" SELECT * FROM dialogs WHERE account = :account AND i_have_sent = 0 + AND last_message_timestamp > 0 ORDER BY last_message_timestamp DESC """) fun getRequestsFlow(account: String): Flow> /** * Получить количество requests + * Исключает пустые диалоги (без сообщений) */ @Query(""" SELECT COUNT(*) FROM dialogs WHERE account = :account AND i_have_sent = 0 + AND last_message_timestamp > 0 """) fun getRequestsCountFlow(account: String): Flow @@ -419,7 +425,7 @@ interface DialogDao { * 1. Берем последнее сообщение (по timestamp DESC) * 2. Считаем количество непрочитанных сообщений (from_me = 0 AND read = 0) * 3. Вычисляем i_have_sent = 1 если есть исходящие сообщения (from_me = 1) - как sended в Архиве - * 4. Обновляем диалог или создаем новый + * 4. Обновляем диалог или создаем новый ТОЛЬКО если есть сообщения! */ @Query(""" INSERT OR REPLACE INTO dialogs ( @@ -494,6 +500,12 @@ interface DialogDao { 0 ) END AS i_have_sent + WHERE EXISTS ( + SELECT 1 FROM messages + WHERE account = :account + AND ((from_public_key = :opponentKey AND to_public_key = :account) + OR (from_public_key = :account AND to_public_key = :opponentKey)) + ) """) suspend fun updateDialogFromMessages(account: String, opponentKey: String) } diff --git a/app/src/main/java/com/rosetta/messenger/network/Packets.kt b/app/src/main/java/com/rosetta/messenger/network/Packets.kt index a7ead26..aedb03e 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Packets.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Packets.kt @@ -454,8 +454,8 @@ class PacketChunk : Packet() { } /** - * Push Token packet (ID: 0x0A) - * Отправка FCM/APNS токена на сервер для push-уведомлений + * Push Token packet (ID: 0x0A) - DEPRECATED + * Старый формат, заменен на PacketPushNotification (0x10) */ class PacketPushToken : Packet() { var privateKey: String = "" @@ -482,3 +482,39 @@ class PacketPushToken : Packet() { return stream } } + +/** + * Push Notification Action + */ +enum class PushNotificationAction(val value: Int) { + SUBSCRIBE(0), + UNSUBSCRIBE(1) +} + +/** + * Push Notification packet (ID: 0x10) + * Отправка FCM/APNS токена на сервер для push-уведомлений (новый формат) + * Совместим с React Native версией + */ +class PacketPushNotification : Packet() { + var notificationsToken: String = "" + var action: PushNotificationAction = PushNotificationAction.SUBSCRIBE + + override fun getPacketId(): Int = 0x10 + + override fun receive(stream: Stream) { + notificationsToken = stream.readString() + action = when (stream.readInt8()) { + 1 -> PushNotificationAction.UNSUBSCRIBE + else -> PushNotificationAction.SUBSCRIBE + } + } + + override fun send(): Stream { + val stream = Stream() + stream.writeInt16(getPacketId()) + stream.writeString(notificationsToken) + stream.writeInt8(action.value) + return stream + } +} diff --git a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt index 398cb61..1730a05 100644 --- a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt +++ b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt @@ -51,32 +51,8 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { // Сохраняем токен локально saveFcmToken(token) - // TODO: Отправляем токен на сервер если аккаунт уже залогинен - /* - serviceScope.launch { - try { - val accountManager = AccountManager(applicationContext) - val currentAccount = accountManager.getCurrentAccount() - - if (currentAccount != null) { - Log.d(TAG, "📤 Sending FCM token to server for account: ${currentAccount.publicKey.take(10)}...") - - // Отправляем через протокол - val packet = PacketPushToken().apply { - this.privateKey = CryptoManager.generatePrivateKeyHash(currentAccount.privateKey) - this.publicKey = currentAccount.publicKey - this.pushToken = token - this.platform = "android" - } - - ProtocolManager.send(packet) - Log.d(TAG, "✅ FCM token sent to server") - } - } catch (e: Exception) { - Log.e(TAG, "❌ Error sending FCM token to server", e) - } - } - */ + // 📤 Токен будет отправлен на сервер после успешного логина в MainActivity + Log.d(TAG, "💾 FCM token saved. Will be sent to server after login.") } /** 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 7a02231..cc3e2c0 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 @@ -1283,31 +1283,59 @@ fun ChatDetailScreen( showDeleteConfirm = false scope.launch { try { + android.util.Log.d("ChatDetail", "🗑️ ========== DELETE CHAT START ==========") + android.util.Log.d("ChatDetail", "🗑️ currentUserPublicKey=${currentUserPublicKey}") + android.util.Log.d("ChatDetail", "🗑️ user.publicKey=${user.publicKey}") + // Вычисляем правильный dialog_key (отсортированная комбинация ключей) val dialogKey = if (currentUserPublicKey < user.publicKey) { "$currentUserPublicKey:${user.publicKey}" } else { "${user.publicKey}:$currentUserPublicKey" } + android.util.Log.d("ChatDetail", "🗑️ dialogKey=$dialogKey") + + // 🗑️ Очищаем ВСЕ кэши сообщений + com.rosetta.messenger.data.MessageRepository.getInstance(context).clearDialogCache(user.publicKey) + // 🗑️ Очищаем кэш ChatViewModel + ChatViewModel.clearCacheForOpponent(user.publicKey) + + // Проверяем количество сообщений до удаления + val countBefore = database.messageDao().getMessageCount(currentUserPublicKey, dialogKey) + android.util.Log.d("ChatDetail", "🗑️ Messages BEFORE delete: $countBefore") // Удаляем все сообщения из диалога по dialog_key - database.messageDao().deleteDialog( + val deletedByKey = database.messageDao().deleteDialog( account = currentUserPublicKey, dialogKey = dialogKey ) + android.util.Log.d("ChatDetail", "🗑️ Deleted by dialogKey: $deletedByKey") + // Также пробуем удалить по from/to ключам (на всякий случай) - database.messageDao().deleteMessagesBetweenUsers( + val deletedBetween = database.messageDao().deleteMessagesBetweenUsers( account = currentUserPublicKey, user1 = user.publicKey, user2 = currentUserPublicKey ) + android.util.Log.d("ChatDetail", "🗑️ Deleted between users: $deletedBetween") + + // Проверяем количество сообщений после удаления + val countAfter = database.messageDao().getMessageCount(currentUserPublicKey, dialogKey) + android.util.Log.d("ChatDetail", "🗑️ Messages AFTER delete: $countAfter") + // Очищаем кеш диалога database.dialogDao().deleteDialog( account = currentUserPublicKey, opponentKey = user.publicKey ) + android.util.Log.d("ChatDetail", "🗑️ Dialog deleted from DB") + + // Проверяем что диалог удален + val dialogAfter = database.dialogDao().getDialog(currentUserPublicKey, user.publicKey) + android.util.Log.d("ChatDetail", "🗑️ Dialog after: ${dialogAfter?.opponentKey ?: "NULL (deleted)"}") + android.util.Log.d("ChatDetail", "🗑️ ========== DELETE CHAT COMPLETE ==========") } catch (e: Exception) { - // Error deleting chat + android.util.Log.e("ChatDetail", "🗑️ DELETE ERROR: ${e.message}", e) } // Выходим ПОСЛЕ удаления onBack() 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 8d798ae..ab3ab8d 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 @@ -33,6 +33,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private const val TAG = "ChatViewModel" private const val PAGE_SIZE = 30 private const val DECRYPT_CHUNK_SIZE = 5 // Расшифровываем по 5 сообщений за раз + + // 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List) + // Сделан глобальным чтобы можно было очистить при удалении диалога + private val dialogMessagesCache = ConcurrentHashMap>() + + /** + * 🗑️ Очистить кэш сообщений для диалога + * Вызывается при удалении диалога + */ + fun clearDialogCache(dialogKey: String) { + android.util.Log.d(TAG, "🗑️ Clearing ChatViewModel cache for: $dialogKey") + dialogMessagesCache.remove(dialogKey) + } + + /** + * 🗑️ Очистить кэш по publicKey собеседника + * Удаляет все ключи содержащие этот publicKey + */ + fun clearCacheForOpponent(opponentKey: String) { + android.util.Log.d(TAG, "🗑️ Clearing ChatViewModel cache for opponent: ${opponentKey.take(16)}...") + val keysToRemove = dialogMessagesCache.keys.filter { it.contains(opponentKey) } + keysToRemove.forEach { + android.util.Log.d(TAG, "🗑️ Removing cache key: $it") + dialogMessagesCache.remove(it) + } + } } // Database @@ -46,10 +72,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 🔥 Кэш расшифрованных сообщений (messageId -> plainText) private val decryptionCache = ConcurrentHashMap() - // 🔥 Кэш сообщений на уровне диалогов (dialogKey -> List) - // При повторном входе в тот же чат - мгновенная загрузка из кэша! - private val dialogMessagesCache = ConcurrentHashMap>() - // Информация о собеседнике private var opponentTitle: String = "" private var opponentUsername: String = "" diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 14b5a04..4445934 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -27,11 +27,14 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.sp +import androidx.compose.ui.platform.LocalContext +import android.content.Context import com.airbnb.lottie.compose.* import com.rosetta.messenger.R import com.rosetta.messenger.data.RecentSearchesManager @@ -186,9 +189,6 @@ fun ChatsListScreen( // 🔥 Пользователи, которые сейчас печатают val typingUsers by ProtocolManager.typingUsers.collectAsState() - // Dialogs from database - val dialogsList by chatsViewModel.dialogs.collectAsState() - // Load dialogs when account is available LaunchedEffect(accountPublicKey, accountPrivateKey) { if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) { @@ -203,7 +203,11 @@ fun ChatsListScreen( // Status dialog state var showStatusDialog by remember { mutableStateOf(false) } - // 📬 Requests screen state + // � FCM токен диалог + var showFcmDialog by remember { mutableStateOf(false) } + val context = LocalContext.current + + // �📬 Requests screen state var showRequestsScreen by remember { mutableStateOf(false) } // 🔥 Используем rememberSaveable чтобы сохранить состояние при навигации @@ -277,6 +281,88 @@ fun ChatsListScreen( containerColor = if (isDarkTheme) Color(0xFF212121) else Color.White ) } + + // 🔔 FCM Token Dialog + if (showFcmDialog) { + val prefs = context.getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE) + val fcmToken = prefs.getString("fcm_token", "No token found") ?: "No token found" + val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current + + AlertDialog( + onDismissRequest = { showFcmDialog = false }, + title = { + Text( + "FCM Push Token", + fontWeight = FontWeight.Bold, + color = textColor + ) + }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Text( + "Token:", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = secondaryTextColor + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + fcmToken, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = textColor, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)) + .padding(8.dp) + .clickable { + clipboardManager.setText(androidx.compose.ui.text.AnnotatedString(fcmToken)) + android.widget.Toast.makeText(context, "Token copied!", android.widget.Toast.LENGTH_SHORT).show() + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + "Status:", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = secondaryTextColor + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + if (fcmToken != "No token found") "✅ Token received" else "❌ Token not received", + fontSize = 14.sp, + color = if (fcmToken != "No token found") Color(0xFF4CAF50) else Color(0xFFF44336) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + "📤 Token is sent to server after login (packet ID: 0x10)", + fontSize = 12.sp, + color = secondaryTextColor + ) + } + }, + confirmButton = { + Button( + onClick = { showFcmDialog = false }, + colors = ButtonDefaults.buttonColors( + containerColor = PrimaryBlue + ) + ) { + Text("Close", color = Color.White) + } + }, + containerColor = if (isDarkTheme) Color(0xFF212121) else Color.White + ) + } // Simple background Box(modifier = Modifier.fillMaxSize().background(backgroundColor)) { @@ -649,6 +735,15 @@ fun ChatsListScreen( actions = { // Search only on main screen if (!showRequestsScreen) { + // 🔔 FCM Debug button + IconButton(onClick = { showFcmDialog = true }) { + Icon( + Icons.Default.Info, + contentDescription = "FCM Token", + tint = textColor + ) + } + IconButton( onClick = { if (protocolState == ProtocolState.AUTHENTICATED) { @@ -700,9 +795,11 @@ fun ChatsListScreen( ) { paddingValues -> // Main content Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { - // 📬 Requests count from ViewModel - val requestsCount by chatsViewModel.requestsCount.collectAsState() - val requests by chatsViewModel.requests.collectAsState() + // � Используем комбинированное состояние для атомарного обновления + // Это предотвращает "дергание" UI когда dialogs и requests обновляются независимо + val chatsState by chatsViewModel.chatsState.collectAsState() + val requests = chatsState.requests + val requestsCount = chatsState.requestsCount // 🎬 Animated content transition between main list and requests AnimatedContent( @@ -744,8 +841,8 @@ fun ChatsListScreen( onUserSelect(user) } ) - } else if (dialogsList.isEmpty() && requestsCount == 0) { - // Empty state with Lottie animation + } else if (chatsState.isEmpty) { + // 🔥 Empty state - используем chatsState.isEmpty для атомарной проверки EmptyChatsState( isDarkTheme = isDarkTheme, modifier = Modifier.fillMaxSize() @@ -753,6 +850,8 @@ fun ChatsListScreen( } else { // Show dialogs list val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) + // 🔥 Берем dialogs из chatsState для консистентности + val currentDialogs = chatsState.dialogs LazyColumn(modifier = Modifier.fillMaxSize()) { // 📬 Requests Section @@ -770,7 +869,7 @@ fun ChatsListScreen( } } - items(dialogsList, key = { it.opponentKey }) { dialog -> + items(currentDialogs, key = { it.opponentKey }) { dialog -> val isSavedMessages = dialog.opponentKey == accountPublicKey // Check if user is blocked var isBlocked by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 57a696d..5f46ee9 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.rosetta.messenger.crypto.CryptoManager +import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.database.DialogEntity import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.PacketOnlineSubscribe @@ -31,6 +32,19 @@ data class DialogUiModel( val verified: Int ) +/** + * 🔥 Комбинированное состояние чатов для атомарного обновления UI + * Это предотвращает "дергание" когда dialogs и requests обновляются независимо + */ +data class ChatsUiState( + val dialogs: List = emptyList(), + val requests: List = emptyList(), + val requestsCount: Int = 0 +) { + val isEmpty: Boolean get() = dialogs.isEmpty() && requestsCount == 0 + val hasContent: Boolean get() = dialogs.isNotEmpty() || requestsCount > 0 +} + /** * ViewModel для списка чатов * Загружает диалоги из базы данных и расшифровывает lastMessage @@ -55,6 +69,19 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio private val _requestsCount = MutableStateFlow(0) val requestsCount: StateFlow = _requestsCount.asStateFlow() + // 🔥 НОВОЕ: Комбинированное состояние - обновляется атомарно! + val chatsState: StateFlow = combine( + _dialogs, + _requests, + _requestsCount + ) { dialogs, requests, count -> + ChatsUiState(dialogs, requests, count) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + ChatsUiState() + ) + // Загрузка private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading.asStateFlow() @@ -234,13 +261,24 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio /** * Удалить диалог и все сообщения с собеседником + * 🔥 ПОЛНОСТЬЮ очищает все данные - диалог, сообщения, кэш */ suspend fun deleteDialog(opponentKey: String) { if (currentAccount.isEmpty()) return try { + android.util.Log.d("ChatsVM", "🗑️ ========== DELETE START ==========") + android.util.Log.d("ChatsVM", "🗑️ opponentKey=${opponentKey}") + android.util.Log.d("ChatsVM", "🗑️ currentAccount=${currentAccount}") + // 🚀 Сразу обновляем UI - удаляем диалог из локального списка _dialogs.value = _dialogs.value.filter { it.opponentKey != opponentKey } + // 🔥 Также удаляем из requests! + _requests.value = _requests.value.filter { it.opponentKey != opponentKey } + // 🔥 Обновляем счетчик requests + _requestsCount.value = _requests.value.size + + android.util.Log.d("ChatsVM", "🗑️ UI updated: dialogs=${_dialogs.value.size}, requests=${_requests.value.size}") // Вычисляем правильный dialog_key (отсортированная комбинация ключей) val dialogKey = if (currentAccount < opponentKey) { @@ -248,26 +286,50 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } else { "$opponentKey:$currentAccount" } + android.util.Log.d("ChatsVM", "🗑️ dialogKey=$dialogKey") + // 🗑️ 1. Очищаем ВСЕ кэши сообщений + MessageRepository.getInstance(getApplication()).clearDialogCache(opponentKey) + // 🗑️ Очищаем кэш ChatViewModel + ChatViewModel.clearCacheForOpponent(opponentKey) - // Удаляем все сообщения из диалога по dialog_key - database.messageDao().deleteDialog( + // 🗑️ 2. Проверяем сколько сообщений в БД до удаления + val messageCountBefore = database.messageDao().getMessageCount(currentAccount, dialogKey) + android.util.Log.d("ChatsVM", "🗑️ Messages BEFORE delete: $messageCountBefore") + + // 🗑️ 3. Удаляем все сообщения из диалога по dialog_key + val deletedByDialogKey = database.messageDao().deleteDialog( account = currentAccount, dialogKey = dialogKey ) - // Также удаляем по from/to ключам (на всякий случай) - database.messageDao().deleteMessagesBetweenUsers( + android.util.Log.d("ChatsVM", "🗑️ Deleted by dialogKey: $deletedByDialogKey") + + // 🗑️ 4. Также удаляем по from/to ключам (на всякий случай - старые сообщения) + val deletedBetweenUsers = database.messageDao().deleteMessagesBetweenUsers( account = currentAccount, user1 = opponentKey, user2 = currentAccount ) - // Очищаем кеш диалога + android.util.Log.d("ChatsVM", "🗑️ Deleted between users: $deletedBetweenUsers") + + // 🗑️ 5. Проверяем сколько сообщений осталось + val messageCountAfter = database.messageDao().getMessageCount(currentAccount, dialogKey) + android.util.Log.d("ChatsVM", "🗑️ Messages AFTER delete: $messageCountAfter") + + // 🗑️ 6. Удаляем диалог из таблицы dialogs database.dialogDao().deleteDialog( account = currentAccount, opponentKey = opponentKey ) + // 🗑️ 7. Проверяем что диалог удален + val dialogAfter = database.dialogDao().getDialog(currentAccount, opponentKey) + android.util.Log.d("ChatsVM", "🗑️ Dialog after delete: ${dialogAfter?.opponentKey ?: "NULL (deleted)"}") + + android.util.Log.d("ChatsVM", "🗑️ ========== DELETE COMPLETE ==========") + } catch (e: Exception) { + android.util.Log.e("ChatsVM", "🗑️ DELETE ERROR: ${e.message}", e) // В случае ошибки - возвращаем диалог обратно (откатываем оптимистичное обновление) // Flow обновится автоматически из БД }