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.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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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 обновится автоматически из БД
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user