feat: Enhance FCM token management by adding unsubscribe logic to prevent duplicate registrations
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user