feat: Implement FCM token handling and dialog cache management; enhance user experience and performance
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
/**
|
||||
* Запросить информацию о пользователе с сервера
|
||||
*/
|
||||
|
||||
@@ -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<List<DialogEntity>>
|
||||
|
||||
/**
|
||||
* Получить 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<List<DialogEntity>>
|
||||
|
||||
/**
|
||||
* Получить количество requests
|
||||
* Исключает пустые диалоги (без сообщений)
|
||||
*/
|
||||
@Query("""
|
||||
SELECT COUNT(*) FROM dialogs
|
||||
WHERE account = :account
|
||||
AND i_have_sent = 0
|
||||
AND last_message_timestamp > 0
|
||||
""")
|
||||
fun getRequestsCountFlow(account: String): Flow<Int>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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 обновится автоматически из БД
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user