feat: Enhance FCM token management by adding unsubscribe logic to prevent duplicate registrations

This commit is contained in:
2026-02-26 14:56:21 +05:00
parent 48861633ee
commit f526a442b0
11 changed files with 1007 additions and 309 deletions

View File

@@ -14,6 +14,7 @@ import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.*
import com.rosetta.messenger.ui.chats.models.*
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.MessageLogger
import com.rosetta.messenger.utils.MessageThrottleManager
import java.util.Date
import java.util.UUID
@@ -218,8 +219,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Отслеживание прочитанных сообщений - храним timestamp последнего прочитанного
private var lastReadMessageTimestamp = 0L
// Флаг что read receipt уже отправлен для текущего диалога
private var readReceiptSentForCurrentDialog = false
private fun sortMessagesAscending(messages: List<ChatMessage>): List<ChatMessage> =
messages.sortedWith(chatMessageAscComparator)
@@ -580,7 +579,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
hasMoreMessages = true
isLoadingMessages = false
lastReadMessageTimestamp = 0L
readReceiptSentForCurrentDialog = false
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога
isDialogActive = true // 🔥 Диалог активен!
@@ -891,6 +889,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} else {
dialogDao.updateDialogFromMessages(account, opponent)
}
// 👁️ Отправляем read receipt — как в desktop (marks read + sends packet)
if (isDialogActive && !isSavedMessages) {
sendReadReceiptToOpponent()
}
} catch (e: Exception) {}
}
@@ -1096,7 +1099,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
status =
when (entity.delivered) {
0 -> MessageStatus.SENDING
1 -> MessageStatus.DELIVERED
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
2 -> MessageStatus.SENT
3 -> MessageStatus.READ
else -> MessageStatus.SENT
@@ -3755,11 +3758,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val privateKey = myPrivateKey ?: return
// Обновляем timestamp последнего прочитанного
// 🔥 Проверяем timestamp ДО отправки — если не изменился, не шлём повторно
val lastIncoming = latestIncomingMessage(_messages.value)
if (lastIncoming != null) {
lastReadMessageTimestamp = lastIncoming.timestamp.time
}
if (lastIncoming == null) return
val incomingTs = lastIncoming.timestamp.time
if (incomingTs <= lastReadMessageTimestamp) return
viewModelScope.launch(Dispatchers.IO) {
try {
@@ -3774,8 +3777,27 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
ProtocolManager.send(packet)
readReceiptSentForCurrentDialog = true
} catch (e: Exception) {}
// ✅ Обновляем timestamp ПОСЛЕ успешной отправки
lastReadMessageTimestamp = incomingTs
MessageLogger.logReadReceiptSent(opponent)
} catch (e: Exception) {
MessageLogger.logReadReceiptFailed(opponent, e)
// 🔄 Retry через 2с (desktop буферизует через WebSocket, мы ретраим вручную)
try {
kotlinx.coroutines.delay(2000)
ProtocolManager.send(
PacketRead().apply {
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
fromPublicKey = sender
toPublicKey = opponent
}
)
lastReadMessageTimestamp = incomingTs
MessageLogger.logReadReceiptSent(opponent, retry = true)
} catch (retryEx: Exception) {
MessageLogger.logReadReceiptFailed(opponent, retryEx, retry = true)
}
}
}
}
@@ -3908,7 +3930,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ProtocolManager.unwaitPacket(0x05, onlinePacketHandler)
lastReadMessageTimestamp = 0L
readReceiptSentForCurrentDialog = false
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке
opponentKey = null
}

View File

@@ -235,7 +235,8 @@ fun ChatsListScreen(
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
onAddAccount: () -> Unit = {},
onSwitchAccount: (String) -> Unit = {},
onDeleteAccountFromSidebar: (String) -> Unit = {}
onDeleteAccountFromSidebar: (String) -> Unit = {},
onLogsClick: () -> Unit = {}
) {
// Theme transition state
var hasInitialized by remember { mutableStateOf(false) }
@@ -1148,6 +1149,22 @@ fun ChatsListScreen(
}
)
// 📋 Logs
DrawerMenuItemEnhanced(
icon = TablerIcons.Bug,
text = "Logs",
iconColor = menuIconColor,
textColor = menuTextColor,
onClick = {
scope.launch {
drawerState.close()
kotlinx.coroutines
.delay(100)
onLogsClick()
}
}
)
}
// ═══════════════════════════════════════════════════════════
@@ -1840,6 +1857,7 @@ fun ChatsListScreen(
pinnedChats
) {
chatsState.dialogs
.distinctBy { it.opponentKey }
.sortedWith(
compareByDescending<
DialogUiModel> {

View File

@@ -261,7 +261,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedDialogs ->
_dialogs.value = decryptedDialogs
// Deduplicate by opponentKey to prevent LazyColumn crash
// (Key "X" was already used)
_dialogs.value = decryptedDialogs.distinctBy { it.opponentKey }
// 🚀 Убираем skeleton после первой загрузки
if (_isLoading.value) _isLoading.value = false
@@ -352,7 +354,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedRequests -> _requests.value = decryptedRequests }
.collect { decryptedRequests ->
_requests.value = decryptedRequests.distinctBy { it.opponentKey }
}
}
// 📊 Подписываемся на количество requests

View File

@@ -70,6 +70,7 @@ import android.content.Intent
import android.webkit.MimeTypeMap
import java.io.ByteArrayInputStream
import java.io.File
import java.security.MessageDigest
import kotlin.math.min
private const val TAG = "AttachmentComponents"
@@ -86,6 +87,15 @@ private fun logPhotoDebug(message: String) {
AttachmentDownloadDebugLogger.log(message)
}
private fun shortDebugHash(bytes: ByteArray): String {
return try {
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
digest.copyOfRange(0, 6).joinToString("") { "%02x".format(it) }
} catch (_: Exception) {
"shaerr"
}
}
/**
* Анимированный текст с волнообразными точками.
* Три точки плавно подпрыгивают каскадом с изменением прозрачности.
@@ -1016,13 +1026,13 @@ fun ImageAttachment(
downloadStatus = DownloadStatus.ERROR
errorLabel = "Error"
logPhotoDebug(
"Image download ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
"Image download ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}, stack=${e.stackTraceToString().take(200)}"
)
} catch (e: OutOfMemoryError) {
System.gc()
downloadStatus = DownloadStatus.ERROR
errorLabel = "Error"
logPhotoDebug("Image OOM: id=$idShort")
logPhotoDebug("Image OOM: id=$idShort, availMem=${Runtime.getRuntime().freeMemory() / 1024}KB")
}
}
} else {
@@ -2341,21 +2351,57 @@ private suspend fun processDownloadedImage(
onStatus(DownloadStatus.DECRYPTING)
// Расшифровываем ключ
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
logPhotoDebug("Key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}")
val keyCandidates: List<ByteArray>
try {
keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
if (keyCandidates.isEmpty()) {
throw IllegalArgumentException("empty key candidates")
}
logPhotoDebug("Key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${keyCandidates.first().size}")
keyCandidates.forEachIndexed { idx, candidate ->
val keyHead = candidate.take(8).joinToString("") { "%02x".format(it.toInt() and 0xff) }
logPhotoDebug("Key material[$idx]: id=$idShort, keyFp=${shortDebugHash(candidate)}, keyHead=$keyHead, keySize=${candidate.size}")
}
} catch (e: Exception) {
onError("Error")
onStatus(DownloadStatus.ERROR)
val keyPrefix = if (chachaKey.startsWith("sync:")) "sync" else "ecdh"
logPhotoDebug("Key decrypt FAILED: id=$idShort, keyType=$keyPrefix, keyLen=${chachaKey.length}, err=${e.javaClass.simpleName}: ${e.message?.take(80)}")
return
}
// Расшифровываем контент
val decryptStartTime = System.currentTimeMillis()
val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
var successKeyIdx = -1
var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
val attempt = MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(encryptedContent, keyCandidate)
if (attempt.decrypted != null) {
successKeyIdx = idx
decryptDebug = attempt
break
}
// Keep last trace for diagnostics if all fail.
decryptDebug = attempt
}
val decrypted = decryptDebug.decrypted
val decryptTime = System.currentTimeMillis() - decryptStartTime
onProgress(0.8f)
if (decrypted != null) {
var decodedBitmap: Bitmap? = null
var saved = false
logPhotoDebug("Blob decrypt OK: id=$idShort, time=${decryptTime}ms")
logPhotoDebug("Blob decrypt OK: id=$idShort, time=${decryptTime}ms, decryptedLen=${decrypted.length}, keyIdx=$successKeyIdx")
decryptDebug.trace.lastOrNull { it.contains("SUCCESS") }?.let { successLine ->
logPhotoDebug("Blob decrypt trace: id=$idShort, $successLine")
}
withContext(Dispatchers.IO) {
decodedBitmap = base64ToBitmap(decrypted)
try {
decodedBitmap = base64ToBitmap(decrypted)
} catch (oom: OutOfMemoryError) {
System.gc()
logPhotoDebug("Bitmap OOM: id=$idShort, decryptedLen=${decrypted.length}")
}
if (decodedBitmap != null) {
onBitmap(decodedBitmap)
ImageBitmapCache.put(cacheKey, decodedBitmap!!)
@@ -2376,12 +2422,18 @@ private suspend fun processDownloadedImage(
} else {
onError("Error")
onStatus(DownloadStatus.ERROR)
logPhotoDebug("Image decode FAILED: id=$idShort")
val preview = decrypted.take(30).replace("\n", "")
logPhotoDebug("Image decode FAILED: id=$idShort, decryptedLen=${decrypted.length}, preview=$preview")
}
} else {
onError("Error")
onStatus(DownloadStatus.ERROR)
logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms")
val keyPrefix = if (chachaKey.startsWith("sync:")) "sync" else "ecdh"
val firstKeySize = keyCandidates.firstOrNull()?.size ?: -1
logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms, contentLen=${encryptedContent.length}, keyType=$keyPrefix, keyNonceSize=$firstKeySize, keyCandidates=${keyCandidates.size}")
decryptDebug.trace.take(96).forEachIndexed { index, line ->
logPhotoDebug("Blob decrypt TRACE[$index]: id=$idShort, $line")
}
}
}
@@ -2415,20 +2467,39 @@ internal suspend fun downloadAndDecryptImage(
"Helper CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
)
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
val keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
if (keyCandidates.isEmpty()) return@withContext null
val plainKeyAndNonce = keyCandidates.first()
logPhotoDebug(
"Helper key decrypt OK: id=$idShort, keySize=${plainKeyAndNonce.size}"
"Helper key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${plainKeyAndNonce.size}"
)
logPhotoDebug(
"Helper key material: id=$idShort, keyFp=${shortDebugHash(plainKeyAndNonce)}"
)
// Primary path for image attachments
var decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent,
plainKeyAndNonce
)
var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
var decrypted: String? = null
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
val attempt =
MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(
encryptedContent,
keyCandidate
)
if (attempt.decrypted != null) {
decryptDebug = attempt
decrypted = attempt.decrypted
logPhotoDebug("Helper decrypt OK: id=$idShort, keyIdx=$idx")
break
}
decryptDebug = attempt
}
// Fallback for legacy payloads
if (decrypted.isNullOrEmpty()) {
decryptDebug.trace.takeLast(12).forEachIndexed { index, line ->
logPhotoDebug("Helper decrypt TRACE[$index]: id=$idShort, $line")
}
decrypted =
try {
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)

View File

@@ -9,7 +9,7 @@ import java.util.Date
import java.util.Locale
object AttachmentDownloadDebugLogger {
private const val MAX_LOGS = 200
private const val MAX_LOGS = 1000
private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
private val _logs = MutableStateFlow<List<String>>(emptyList())
@@ -19,6 +19,9 @@ object AttachmentDownloadDebugLogger {
val timestamp = dateFormat.format(Date())
val line = "[$timestamp] 🖼️ $message"
_logs.update { current -> (current + line).takeLast(MAX_LOGS) }
// Всегда дублируем в debug logs чата напрямую через ProtocolManager
// (не через MessageLogger, чтобы обойти isEnabled гейт)
com.rosetta.messenger.network.ProtocolManager.addLog("🖼️ $message")
}
fun clear() {