feat: Implement FCM token handling and dialog cache management; enhance user experience and performance

This commit is contained in:
2026-01-17 05:53:27 +05:00
parent c9724b3bb7
commit 52ffc22763
9 changed files with 333 additions and 65 deletions

View File

@@ -43,8 +43,10 @@ import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.data.RecentSearchesManager 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.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.ui.auth.AccountInfo import com.rosetta.messenger.ui.auth.AccountInfo
import com.rosetta.messenger.ui.auth.AuthFlow import com.rosetta.messenger.ui.auth.AuthFlow
import com.rosetta.messenger.ui.chats.ChatsListScreen 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.splash.SplashScreen
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var preferencesManager: PreferencesManager private lateinit var preferencesManager: PreferencesManager
@@ -331,19 +334,32 @@ class MainActivity : ComponentActivity() {
return@launch return@launch
} }
Log.d(TAG, "📤 Sending FCM token to server...") // 🔥 КРИТИЧНО: Ждем пока протокол станет AUTHENTICATED
Log.d(TAG, " Token (short): ${token.take(20)}...") var waitAttempts = 0
Log.d(TAG, " PublicKey: ${account.publicKey.take(20)}...") 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 { if (ProtocolManager.state.value != ProtocolState.AUTHENTICATED) {
this.privateKey = account.privateKey Log.e(TAG, "❌ Cannot send FCM token: Protocol not authenticated after 5 seconds")
this.publicKey = account.publicKey return@launch
this.pushToken = token }
this.platform = "android"
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) 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) { } catch (e: Exception) {
Log.e(TAG, "❌ Error sending FCM token to server", e) Log.e(TAG, "❌ Error sending FCM token to server", e)
} }

View File

@@ -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")
}
/** /**
* Запросить информацию о пользователе с сервера * Запросить информацию о пользователе с сервера
*/ */

View File

@@ -236,13 +236,13 @@ interface MessageDao {
suspend fun getUnreadCountForDialog(account: String, opponentKey: String): Int suspend fun getUnreadCountForDialog(account: String, opponentKey: String): Int
/** /**
* Удалить все сообщения диалога * Удалить все сообщения диалога (возвращает количество удалённых)
*/ */
@Query("DELETE FROM messages WHERE account = :account AND dialog_key = :dialogKey") @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(""" @Query("""
DELETE FROM messages DELETE FROM messages
@@ -251,7 +251,7 @@ interface MessageDao {
(from_public_key = :user2 AND to_public_key = :user1) (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 (диалоги без исходящих сообщений от нас) * Исключает requests (диалоги без исходящих сообщений от нас)
* Исключает пустые диалоги (без сообщений)
*/ */
@Query(""" @Query("""
SELECT * FROM dialogs SELECT * FROM dialogs
WHERE account = :account WHERE account = :account
AND i_have_sent = 1 AND i_have_sent = 1
AND last_message_timestamp > 0
ORDER BY last_message_timestamp DESC ORDER BY last_message_timestamp DESC
""") """)
fun getDialogsFlow(account: String): Flow<List<DialogEntity>> fun getDialogsFlow(account: String): Flow<List<DialogEntity>>
/** /**
* Получить requests - диалоги где нам писали, но мы не отвечали * Получить requests - диалоги где нам писали, но мы не отвечали
* Исключает пустые диалоги (без сообщений)
*/ */
@Query(""" @Query("""
SELECT * FROM dialogs SELECT * FROM dialogs
WHERE account = :account WHERE account = :account
AND i_have_sent = 0 AND i_have_sent = 0
AND last_message_timestamp > 0
ORDER BY last_message_timestamp DESC ORDER BY last_message_timestamp DESC
""") """)
fun getRequestsFlow(account: String): Flow<List<DialogEntity>> fun getRequestsFlow(account: String): Flow<List<DialogEntity>>
/** /**
* Получить количество requests * Получить количество requests
* Исключает пустые диалоги (без сообщений)
*/ */
@Query(""" @Query("""
SELECT COUNT(*) FROM dialogs SELECT COUNT(*) FROM dialogs
WHERE account = :account WHERE account = :account
AND i_have_sent = 0 AND i_have_sent = 0
AND last_message_timestamp > 0
""") """)
fun getRequestsCountFlow(account: String): Flow<Int> fun getRequestsCountFlow(account: String): Flow<Int>
@@ -419,7 +425,7 @@ interface DialogDao {
* 1. Берем последнее сообщение (по timestamp DESC) * 1. Берем последнее сообщение (по timestamp DESC)
* 2. Считаем количество непрочитанных сообщений (from_me = 0 AND read = 0) * 2. Считаем количество непрочитанных сообщений (from_me = 0 AND read = 0)
* 3. Вычисляем i_have_sent = 1 если есть исходящие сообщения (from_me = 1) - как sended в Архиве * 3. Вычисляем i_have_sent = 1 если есть исходящие сообщения (from_me = 1) - как sended в Архиве
* 4. Обновляем диалог или создаем новый * 4. Обновляем диалог или создаем новый ТОЛЬКО если есть сообщения!
*/ */
@Query(""" @Query("""
INSERT OR REPLACE INTO dialogs ( INSERT OR REPLACE INTO dialogs (
@@ -494,6 +500,12 @@ interface DialogDao {
0 0
) )
END AS i_have_sent 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) suspend fun updateDialogFromMessages(account: String, opponentKey: String)
} }

View File

@@ -454,8 +454,8 @@ class PacketChunk : Packet() {
} }
/** /**
* Push Token packet (ID: 0x0A) * Push Token packet (ID: 0x0A) - DEPRECATED
* Отправка FCM/APNS токена на сервер для push-уведомлений * Старый формат, заменен на PacketPushNotification (0x10)
*/ */
class PacketPushToken : Packet() { class PacketPushToken : Packet() {
var privateKey: String = "" var privateKey: String = ""
@@ -482,3 +482,39 @@ class PacketPushToken : Packet() {
return stream 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
}
}

View File

@@ -51,32 +51,8 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
// Сохраняем токен локально // Сохраняем токен локально
saveFcmToken(token) saveFcmToken(token)
// TODO: Отправляем токен на сервер если аккаунт уже залогинен // 📤 Токен будет отправлен на сервер после успешного логина в MainActivity
/* Log.d(TAG, "💾 FCM token saved. Will be sent to server after login.")
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)
}
}
*/
} }
/** /**

View File

@@ -1283,31 +1283,59 @@ fun ChatDetailScreen(
showDeleteConfirm = false showDeleteConfirm = false
scope.launch { scope.launch {
try { 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 (отсортированная комбинация ключей) // Вычисляем правильный dialog_key (отсортированная комбинация ключей)
val dialogKey = if (currentUserPublicKey < user.publicKey) { val dialogKey = if (currentUserPublicKey < user.publicKey) {
"$currentUserPublicKey:${user.publicKey}" "$currentUserPublicKey:${user.publicKey}"
} else { } else {
"${user.publicKey}:$currentUserPublicKey" "${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 // Удаляем все сообщения из диалога по dialog_key
database.messageDao().deleteDialog( val deletedByKey = database.messageDao().deleteDialog(
account = currentUserPublicKey, account = currentUserPublicKey,
dialogKey = dialogKey dialogKey = dialogKey
) )
android.util.Log.d("ChatDetail", "🗑️ Deleted by dialogKey: $deletedByKey")
// Также пробуем удалить по from/to ключам (на всякий случай) // Также пробуем удалить по from/to ключам (на всякий случай)
database.messageDao().deleteMessagesBetweenUsers( val deletedBetween = database.messageDao().deleteMessagesBetweenUsers(
account = currentUserPublicKey, account = currentUserPublicKey,
user1 = user.publicKey, user1 = user.publicKey,
user2 = currentUserPublicKey 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( database.dialogDao().deleteDialog(
account = currentUserPublicKey, account = currentUserPublicKey,
opponentKey = user.publicKey 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) { } catch (e: Exception) {
// Error deleting chat android.util.Log.e("ChatDetail", "🗑️ DELETE ERROR: ${e.message}", e)
} }
// Выходим ПОСЛЕ удаления // Выходим ПОСЛЕ удаления
onBack() onBack()

View File

@@ -33,6 +33,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private const val TAG = "ChatViewModel" private const val TAG = "ChatViewModel"
private const val PAGE_SIZE = 30 private const val PAGE_SIZE = 30
private const val DECRYPT_CHUNK_SIZE = 5 // Расшифровываем по 5 сообщений за раз private const val DECRYPT_CHUNK_SIZE = 5 // Расшифровываем по 5 сообщений за раз
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
// Сделан глобальным чтобы можно было очистить при удалении диалога
private val dialogMessagesCache = ConcurrentHashMap<String, List<ChatMessage>>()
/**
* 🗑️ Очистить кэш сообщений для диалога
* Вызывается при удалении диалога
*/
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 // Database
@@ -46,10 +72,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 Кэш расшифрованных сообщений (messageId -> plainText) // 🔥 Кэш расшифрованных сообщений (messageId -> plainText)
private val decryptionCache = ConcurrentHashMap<String, String>() private val decryptionCache = ConcurrentHashMap<String, String>()
// 🔥 Кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
// При повторном входе в тот же чат - мгновенная загрузка из кэша!
private val dialogMessagesCache = ConcurrentHashMap<String, List<ChatMessage>>()
// Информация о собеседнике // Информация о собеседнике
private var opponentTitle: String = "" private var opponentTitle: String = ""
private var opponentUsername: String = "" private var opponentUsername: String = ""

View File

@@ -27,11 +27,14 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.ui.text.font.FontWeight 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.platform.LocalContext
import android.content.Context
import com.airbnb.lottie.compose.* import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.RecentSearchesManager
@@ -186,9 +189,6 @@ fun ChatsListScreen(
// 🔥 Пользователи, которые сейчас печатают // 🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.collectAsState() val typingUsers by ProtocolManager.typingUsers.collectAsState()
// Dialogs from database
val dialogsList by chatsViewModel.dialogs.collectAsState()
// Load dialogs when account is available // Load dialogs when account is available
LaunchedEffect(accountPublicKey, accountPrivateKey) { LaunchedEffect(accountPublicKey, accountPrivateKey) {
if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) { if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) {
@@ -203,7 +203,11 @@ fun ChatsListScreen(
// Status dialog state // Status dialog state
var showStatusDialog by remember { mutableStateOf(false) } var showStatusDialog by remember { mutableStateOf(false) }
// 📬 Requests screen state // <EFBFBD> FCM токен диалог
var showFcmDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
// <20>📬 Requests screen state
var showRequestsScreen by remember { mutableStateOf(false) } var showRequestsScreen by remember { mutableStateOf(false) }
// 🔥 Используем rememberSaveable чтобы сохранить состояние при навигации // 🔥 Используем rememberSaveable чтобы сохранить состояние при навигации
@@ -277,6 +281,88 @@ fun ChatsListScreen(
containerColor = if (isDarkTheme) Color(0xFF212121) else Color.White 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 // Simple background
Box(modifier = Modifier.fillMaxSize().background(backgroundColor)) { Box(modifier = Modifier.fillMaxSize().background(backgroundColor)) {
@@ -649,6 +735,15 @@ fun ChatsListScreen(
actions = { actions = {
// Search only on main screen // Search only on main screen
if (!showRequestsScreen) { if (!showRequestsScreen) {
// 🔔 FCM Debug button
IconButton(onClick = { showFcmDialog = true }) {
Icon(
Icons.Default.Info,
contentDescription = "FCM Token",
tint = textColor
)
}
IconButton( IconButton(
onClick = { onClick = {
if (protocolState == ProtocolState.AUTHENTICATED) { if (protocolState == ProtocolState.AUTHENTICATED) {
@@ -700,9 +795,11 @@ fun ChatsListScreen(
) { paddingValues -> ) { paddingValues ->
// Main content // Main content
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
// 📬 Requests count from ViewModel // <EFBFBD> Используем комбинированное состояние для атомарного обновления
val requestsCount by chatsViewModel.requestsCount.collectAsState() // Это предотвращает "дергание" UI когда dialogs и requests обновляются независимо
val requests by chatsViewModel.requests.collectAsState() val chatsState by chatsViewModel.chatsState.collectAsState()
val requests = chatsState.requests
val requestsCount = chatsState.requestsCount
// 🎬 Animated content transition between main list and requests // 🎬 Animated content transition between main list and requests
AnimatedContent( AnimatedContent(
@@ -744,8 +841,8 @@ fun ChatsListScreen(
onUserSelect(user) onUserSelect(user)
} }
) )
} else if (dialogsList.isEmpty() && requestsCount == 0) { } else if (chatsState.isEmpty) {
// Empty state with Lottie animation // 🔥 Empty state - используем chatsState.isEmpty для атомарной проверки
EmptyChatsState( EmptyChatsState(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
@@ -753,6 +850,8 @@ fun ChatsListScreen(
} else { } else {
// Show dialogs list // Show dialogs list
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
// 🔥 Берем dialogs из chatsState для консистентности
val currentDialogs = chatsState.dialogs
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
// 📬 Requests Section // 📬 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 val isSavedMessages = dialog.opponentKey == accountPublicKey
// Check if user is blocked // Check if user is blocked
var isBlocked by remember { mutableStateOf(false) } var isBlocked by remember { mutableStateOf(false) }

View File

@@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.database.DialogEntity import com.rosetta.messenger.database.DialogEntity
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.PacketOnlineSubscribe import com.rosetta.messenger.network.PacketOnlineSubscribe
@@ -31,6 +32,19 @@ data class DialogUiModel(
val verified: Int val verified: Int
) )
/**
* 🔥 Комбинированное состояние чатов для атомарного обновления UI
* Это предотвращает "дергание" когда dialogs и requests обновляются независимо
*/
data class ChatsUiState(
val dialogs: List<DialogUiModel> = emptyList(),
val requests: List<DialogUiModel> = emptyList(),
val requestsCount: Int = 0
) {
val isEmpty: Boolean get() = dialogs.isEmpty() && requestsCount == 0
val hasContent: Boolean get() = dialogs.isNotEmpty() || requestsCount > 0
}
/** /**
* ViewModel для списка чатов * ViewModel для списка чатов
* Загружает диалоги из базы данных и расшифровывает lastMessage * Загружает диалоги из базы данных и расшифровывает lastMessage
@@ -55,6 +69,19 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
private val _requestsCount = MutableStateFlow(0) private val _requestsCount = MutableStateFlow(0)
val requestsCount: StateFlow<Int> = _requestsCount.asStateFlow() val requestsCount: StateFlow<Int> = _requestsCount.asStateFlow()
// 🔥 НОВОЕ: Комбинированное состояние - обновляется атомарно!
val chatsState: StateFlow<ChatsUiState> = combine(
_dialogs,
_requests,
_requestsCount
) { dialogs, requests, count ->
ChatsUiState(dialogs, requests, count)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
ChatsUiState()
)
// Загрузка // Загрузка
private val _isLoading = MutableStateFlow(false) private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow() val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
@@ -234,13 +261,24 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
/** /**
* Удалить диалог и все сообщения с собеседником * Удалить диалог и все сообщения с собеседником
* 🔥 ПОЛНОСТЬЮ очищает все данные - диалог, сообщения, кэш
*/ */
suspend fun deleteDialog(opponentKey: String) { suspend fun deleteDialog(opponentKey: String) {
if (currentAccount.isEmpty()) return if (currentAccount.isEmpty()) return
try { try {
android.util.Log.d("ChatsVM", "🗑️ ========== DELETE START ==========")
android.util.Log.d("ChatsVM", "🗑️ opponentKey=${opponentKey}")
android.util.Log.d("ChatsVM", "🗑️ currentAccount=${currentAccount}")
// 🚀 Сразу обновляем UI - удаляем диалог из локального списка // 🚀 Сразу обновляем UI - удаляем диалог из локального списка
_dialogs.value = _dialogs.value.filter { it.opponentKey != opponentKey } _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 (отсортированная комбинация ключей) // Вычисляем правильный dialog_key (отсортированная комбинация ключей)
val dialogKey = if (currentAccount < opponentKey) { val dialogKey = if (currentAccount < opponentKey) {
@@ -248,26 +286,50 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} else { } else {
"$opponentKey:$currentAccount" "$opponentKey:$currentAccount"
} }
android.util.Log.d("ChatsVM", "🗑️ dialogKey=$dialogKey")
// 🗑️ 1. Очищаем ВСЕ кэши сообщений
MessageRepository.getInstance(getApplication()).clearDialogCache(opponentKey)
// 🗑️ Очищаем кэш ChatViewModel
ChatViewModel.clearCacheForOpponent(opponentKey)
// Удаляем все сообщения из диалога по dialog_key // 🗑️ 2. Проверяем сколько сообщений в БД до удаления
database.messageDao().deleteDialog( 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, account = currentAccount,
dialogKey = dialogKey dialogKey = dialogKey
) )
// Также удаляем по from/to ключам (на всякий случай) android.util.Log.d("ChatsVM", "🗑️ Deleted by dialogKey: $deletedByDialogKey")
database.messageDao().deleteMessagesBetweenUsers(
// 🗑️ 4. Также удаляем по from/to ключам (на всякий случай - старые сообщения)
val deletedBetweenUsers = database.messageDao().deleteMessagesBetweenUsers(
account = currentAccount, account = currentAccount,
user1 = opponentKey, user1 = opponentKey,
user2 = currentAccount 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( database.dialogDao().deleteDialog(
account = currentAccount, account = currentAccount,
opponentKey = opponentKey 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) { } catch (e: Exception) {
android.util.Log.e("ChatsVM", "🗑️ DELETE ERROR: ${e.message}", e)
// В случае ошибки - возвращаем диалог обратно (откатываем оптимистичное обновление) // В случае ошибки - возвращаем диалог обратно (откатываем оптимистичное обновление)
// Flow обновится автоматически из БД // Flow обновится автоматически из БД
} }