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

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

View File

@@ -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<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
@@ -46,10 +72,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 Кэш расшифрованных сообщений (messageId -> plainText)
private val decryptionCache = ConcurrentHashMap<String, String>()
// 🔥 Кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
// При повторном входе в тот же чат - мгновенная загрузка из кэша!
private val dialogMessagesCache = ConcurrentHashMap<String, List<ChatMessage>>()
// Информация о собеседнике
private var opponentTitle: 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.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
// <EFBFBD> FCM токен диалог
var showFcmDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
// <20>📬 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()
// <EFBFBD> Используем комбинированное состояние для атомарного обновления
// Это предотвращает "дергание" 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) }

View File

@@ -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<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 для списка чатов
* Загружает диалоги из базы данных и расшифровывает lastMessage
@@ -55,6 +69,19 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
private val _requestsCount = MutableStateFlow(0)
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)
val isLoading: StateFlow<Boolean> = _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 обновится автоматически из БД
}