From ff854e919e3d7fa143194c0f8bd8ed4ba3152db1 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 29 Mar 2026 20:45:25 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=BE=D0=B2=20=D0=B8=20=D0=B2?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9:=20=D0=BD=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B2=D1=85=D0=BE=D0=B4=D1=8F=D1=89=D0=B8=D1=85=20=D0=B0=D1=82?= =?UTF-8?q?=D1=82=D0=B0=D1=87=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2,=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?UI=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=B5=D0=BA=20=D0=B7?= =?UTF-8?q?=D0=B2=D0=BE=D0=BD=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../messenger/data/MessageRepository.kt | 57 +++++- .../rosetta/messenger/network/CallManager.kt | 54 +++++- .../messenger/network/MessageAttachment.kt | 6 +- .../messenger/network/PacketMessage.kt | 116 +++++++++++-- .../com/rosetta/messenger/network/Stream.kt | 4 + .../messenger/ui/chats/ChatViewModel.kt | 162 ++++++++++++++++-- .../messenger/ui/chats/ChatsListViewModel.kt | 70 +++++++- .../chats/components/AttachmentComponents.kt | 10 +- 8 files changed, 429 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index ebc44d4..3868f60 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -844,17 +844,20 @@ class MessageRepository private constructor(private val context: Context) { } } + val normalizedIncomingAttachments = + normalizeIncomingAttachments(packet.attachments, plainText) + // 📝 LOG: Расшифровка успешна MessageLogger.logDecryptionSuccess( messageId = messageId, plainTextLength = plainText.length, - attachmentsCount = packet.attachments.size + attachmentsCount = normalizedIncomingAttachments.size ) // Сериализуем attachments в JSON с расшифровкой MESSAGES blob val attachmentsJson = serializeAttachmentsWithDecryption( - packet.attachments, + normalizedIncomingAttachments, packet.chachaKey, privateKey, plainKeyAndNonce, @@ -863,7 +866,7 @@ class MessageRepository private constructor(private val context: Context) { // 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop) processImageAttachments( - packet.attachments, + normalizedIncomingAttachments, packet.chachaKey, privateKey, plainKeyAndNonce, @@ -876,7 +879,7 @@ class MessageRepository private constructor(private val context: Context) { val avatarOwnerKey = if (isGroupMessage) toGroupDialogPublicKey(packet.toPublicKey) else packet.fromPublicKey processAvatarAttachments( - packet.attachments, + normalizedIncomingAttachments, avatarOwnerKey, packet.chachaKey, privateKey, @@ -917,7 +920,7 @@ class MessageRepository private constructor(private val context: Context) { plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст attachments = attachmentsJson, primaryAttachmentType = - resolvePrimaryAttachmentType(packet.attachments), + resolvePrimaryAttachmentType(normalizedIncomingAttachments), dialogKey = dialogKey ) @@ -1703,6 +1706,50 @@ class MessageRepository private constructor(private val context: Context) { return attachments.first().type.value } + /** + * Desktop иногда присылает attachment звонка с некорректным type при поврежденном/пограничном + * пакете (в UI это превращается в пустой пузырь). Для attachment-only сообщения мягко + * нормализуем такой кейс к CALL. + */ + private fun normalizeIncomingAttachments( + attachments: List, + plainText: String + ): List { + if (attachments.isEmpty() || plainText.isNotBlank() || attachments.size != 1) { + return attachments + } + + val first = attachments.first() + if (!isLikelyCallAttachment(first, plainText)) { + return attachments + } + + return when (first.type) { + AttachmentType.CALL -> attachments + else -> { + MessageLogger.debug( + "📥 ATTACHMENT FIXUP: coerced ${first.type} -> CALL for ${first.id.take(8)}..." + ) + listOf(first.copy(type = AttachmentType.CALL)) + } + } + } + + private fun isLikelyCallAttachment(attachment: MessageAttachment, plainText: String): Boolean { + if (plainText.isNotBlank()) return false + if (attachment.blob.isNotBlank()) return false + if (attachment.width > 0 || attachment.height > 0) return false + + val preview = attachment.preview.trim() + if (preview.isEmpty()) return true + + val tail = preview.substringAfterLast("::", preview).trim() + if (tail.toIntOrNull() != null) return true + + return Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE) + .containsMatchIn(preview) + } + private suspend fun upsertSearchIndex(account: String, entity: MessageEntity, plainText: String) { val opponentKey = if (entity.fromMe == 1) entity.toPublicKey.trim() else entity.fromPublicKey.trim() diff --git a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt index 5c1b933..f8ae1df 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt @@ -95,6 +95,7 @@ object CallManager { private const val TAIL_LINES = 300 private const val PROTOCOL_LOG_TAIL_LINES = 180 private const val MAX_LOG_PREFIX = 180 + private const val INCOMING_RING_TIMEOUT_MS = 45_000L private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val secureRandom = SecureRandom() @@ -123,6 +124,7 @@ object CallManager { private var durationJob: Job? = null private var protocolStateJob: Job? = null private var disconnectResetJob: Job? = null + private var incomingRingTimeoutJob: Job? = null private var signalWaiter: ((Packet) -> Unit)? = null private var webRtcWaiter: ((Packet) -> Unit)? = null @@ -231,6 +233,8 @@ object CallManager { sharedPublic = localPublic.toHex() ) keyExchangeSent = true + incomingRingTimeoutJob?.cancel() + incomingRingTimeoutJob = null updateState { it.copy( @@ -245,6 +249,8 @@ object CallManager { fun declineIncomingCall() { val snapshot = _state.value if (snapshot.phase != CallPhase.INCOMING) return + incomingRingTimeoutJob?.cancel() + incomingRingTimeoutJob = null if (ownPublicKey.isNotBlank() && snapshot.peerPublicKey.isNotBlank()) { ProtocolManager.sendCallSignal( signalType = SignalType.END_CALL, @@ -287,19 +293,34 @@ object CallManager { private suspend fun handleSignalPacket(packet: PacketSignalPeer) { breadcrumb("SIG: ${packet.signalType} from=${packet.src.take(8)}… phase=${_state.value.phase}") + val snapshot = _state.value + val currentPhase = snapshot.phase + val currentPeer = snapshot.peerPublicKey when (packet.signalType) { SignalType.END_CALL_BECAUSE_BUSY -> { + if (currentPhase == CallPhase.IDLE) { + breadcrumb("SIG: peer busy IGNORED — no active call") + return + } breadcrumb("SIG: peer busy → reset") resetSession(reason = "User is busy", notifyPeer = false) return } SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED -> { + if (currentPhase == CallPhase.IDLE) { + breadcrumb("SIG: peer disconnected IGNORED — no active call") + return + } breadcrumb("SIG: peer disconnected → reset") resetSession(reason = "Peer disconnected", notifyPeer = false) return } SignalType.END_CALL -> { + if (currentPhase == CallPhase.IDLE) { + breadcrumb("SIG: END_CALL IGNORED — no active call") + return + } breadcrumb("SIG: END_CALL → reset") resetSession(reason = "Call ended", notifyPeer = false) return @@ -307,8 +328,11 @@ object CallManager { else -> Unit } - val currentPeer = _state.value.peerPublicKey val src = packet.src.trim() + if (packet.signalType != SignalType.CALL && currentPhase == CallPhase.IDLE) { + breadcrumb("SIG: ${packet.signalType} IGNORED — no active session") + return + } if (currentPeer.isNotBlank() && src.isNotBlank() && src != currentPeer && src != ownPublicKey) { breadcrumb("SIG: IGNORED (src mismatch: expected=${currentPeer.take(8)}… got=${src.take(8)}…)") return @@ -343,6 +367,18 @@ object CallManager { } appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) } resolvePeerIdentity(incomingPeer) + incomingRingTimeoutJob?.cancel() + incomingRingTimeoutJob = + scope.launch { + delay(INCOMING_RING_TIMEOUT_MS) + val pending = _state.value + if (pending.phase == CallPhase.INCOMING && + pending.peerPublicKey == incomingPeer + ) { + breadcrumb("SIG: incoming timeout (${INCOMING_RING_TIMEOUT_MS}ms) → auto-decline") + resetSession(reason = "Incoming call timeout", notifyPeer = false) + } + } } SignalType.KEY_EXCHANGE -> { breadcrumb("SIG: KEY_EXCHANGE → handleKeyExchange") @@ -833,6 +869,8 @@ object CallManager { durationJob = null disconnectResetJob?.cancel() disconnectResetJob = null + incomingRingTimeoutJob?.cancel() + incomingRingTimeoutJob = null setSpeakerphone(false) _state.value = CallUiState() } @@ -1254,7 +1292,19 @@ object CallManager { } private fun updateState(reducer: (CallUiState) -> CallUiState) { - _state.update(reducer) + _state.update { previous -> + val next = reducer(previous) + if (next.phase != CallPhase.IDLE && next.peerPublicKey.isBlank()) { + next.copy( + peerPublicKey = previous.peerPublicKey, + peerTitle = if (next.peerTitle.isBlank()) previous.peerTitle else next.peerTitle, + peerUsername = + if (next.peerUsername.isBlank()) previous.peerUsername else next.peerUsername + ) + } else { + next + } + } } private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } diff --git a/app/src/main/java/com/rosetta/messenger/network/MessageAttachment.kt b/app/src/main/java/com/rosetta/messenger/network/MessageAttachment.kt index 91849fb..dd7c5b5 100644 --- a/app/src/main/java/com/rosetta/messenger/network/MessageAttachment.kt +++ b/app/src/main/java/com/rosetta/messenger/network/MessageAttachment.kt @@ -10,5 +10,9 @@ data class MessageAttachment( val preview: String = "", // Метаданные: "UUID::metadata" или "filesize::filename" val width: Int = 0, val height: Int = 0, - val localUri: String = "" // 🚀 Локальный URI для мгновенного отображения (optimistic UI) + val localUri: String = "", // 🚀 Локальный URI для мгновенного отображения (optimistic UI) + val transportTag: String = "", + val transportServer: String = "", + val encodedFor: String = "", + val encoder: String = "" ) diff --git a/app/src/main/java/com/rosetta/messenger/network/PacketMessage.kt b/app/src/main/java/com/rosetta/messenger/network/PacketMessage.kt index d45344e..7549405 100644 --- a/app/src/main/java/com/rosetta/messenger/network/PacketMessage.kt +++ b/app/src/main/java/com/rosetta/messenger/network/PacketMessage.kt @@ -15,29 +15,41 @@ class PacketMessage : Packet() { var aesChachaKey: String = "" // ChaCha key+nonce зашифрованный приватным ключом отправителя var attachments: List = emptyList() + private data class ParsedPacketMessage( + val fromPublicKey: String, + val toPublicKey: String, + val content: String, + val chachaKey: String, + val timestamp: Long, + val privateKey: String, + val messageId: String, + val attachments: List, + val aesChachaKey: String + ) + override fun getPacketId(): Int = 0x06 override fun receive(stream: Stream) { - fromPublicKey = stream.readString() - toPublicKey = stream.readString() - content = stream.readString() - chachaKey = stream.readString() - timestamp = stream.readInt64() - privateKey = stream.readString() - messageId = stream.readString() + val startPointer = stream.getReadPointerBits() + val extended = parseFromStream(stream, readExtendedAttachmentMeta = true) + val parsed = + if (extended != null && !stream.hasRemainingBits()) { + extended + } else { + stream.setReadPointerBits(startPointer) + parseFromStream(stream, readExtendedAttachmentMeta = false) + ?: throw IllegalStateException("Failed to parse PacketMessage payload") + } - val attachmentCount = stream.readInt8() - val attachmentsList = mutableListOf() - for (i in 0 until attachmentCount) { - attachmentsList.add(MessageAttachment( - id = stream.readString(), - preview = stream.readString(), - blob = stream.readString(), - type = AttachmentType.fromInt(stream.readInt8()) - )) - } - attachments = attachmentsList - aesChachaKey = stream.readString() + fromPublicKey = parsed.fromPublicKey + toPublicKey = parsed.toPublicKey + content = parsed.content + chachaKey = parsed.chachaKey + timestamp = parsed.timestamp + privateKey = parsed.privateKey + messageId = parsed.messageId + attachments = parsed.attachments + aesChachaKey = parsed.aesChachaKey } override fun send(): Stream { @@ -62,4 +74,70 @@ class PacketMessage : Packet() { return stream } + + private fun parseFromStream( + parser: Stream, + readExtendedAttachmentMeta: Boolean + ): ParsedPacketMessage? { + return runCatching { + val parsedFromPublicKey = parser.readString() + val parsedToPublicKey = parser.readString() + val parsedContent = parser.readString() + val parsedChachaKey = parser.readString() + val parsedTimestamp = parser.readInt64() + val parsedPrivateKey = parser.readString() + val parsedMessageId = parser.readString() + + val attachmentCount = parser.readInt8().coerceAtLeast(0) + val parsedAttachments = ArrayList(attachmentCount) + repeat(attachmentCount) { + val id = parser.readString() + val preview = parser.readString() + val blob = parser.readString() + val type = AttachmentType.fromInt(parser.readInt8()) + + val transportTag: String + val transportServer: String + val encodedFor: String + val encoder: String + if (readExtendedAttachmentMeta) { + transportTag = parser.readString() + transportServer = parser.readString() + encodedFor = parser.readString() + encoder = parser.readString() + } else { + transportTag = "" + transportServer = "" + encodedFor = "" + encoder = "" + } + + parsedAttachments.add( + MessageAttachment( + id = id, + preview = preview, + blob = blob, + type = type, + transportTag = transportTag, + transportServer = transportServer, + encodedFor = encodedFor, + encoder = encoder + ) + ) + } + + val parsedAesChachaKey = parser.readString() + ParsedPacketMessage( + fromPublicKey = parsedFromPublicKey, + toPublicKey = parsedToPublicKey, + content = parsedContent, + chachaKey = parsedChachaKey, + timestamp = parsedTimestamp, + privateKey = parsedPrivateKey, + messageId = parsedMessageId, + attachments = parsedAttachments, + aesChachaKey = parsedAesChachaKey + ) + }.getOrNull() + } } diff --git a/app/src/main/java/com/rosetta/messenger/network/Stream.kt b/app/src/main/java/com/rosetta/messenger/network/Stream.kt index c2e825f..44dbcf0 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Stream.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Stream.kt @@ -19,6 +19,10 @@ class Stream(stream: ByteArray = ByteArray(0)) { fun getReadPointerBits(): Int = _readPointer + fun setReadPointerBits(bits: Int) { + _readPointer = bits.coerceIn(0, getTotalBits()) + } + fun getTotalBits(): Int = _stream.size * 8 fun getRemainingBits(): Int = getTotalBits() - _readPointer diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index d969a3a..fb089a7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -1314,7 +1314,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } else {} // Парсим все attachments (IMAGE, FILE, AVATAR) - val parsedAttachments = parseAllAttachments(entity.attachments) + val parsedAttachments = + normalizeIncomingCallAttachments( + parseAllAttachments(entity.attachments), + displayText + ) + val finalAttachments = + if (parsedAttachments.isEmpty() && displayText.isBlank()) { + parseCallAttachmentFallback(entity.attachments, entity.messageId)?.let { + listOf(it) + } ?: parsedAttachments + } else { + parsedAttachments + } val myKey = myPublicKey.orEmpty().trim() val senderKey = entity.fromPublicKey.trim() @@ -1345,7 +1357,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { }, replyData = if (forwardedMessages.isNotEmpty()) null else replyData, forwardedMessages = forwardedMessages, - attachments = parsedAttachments, + attachments = finalAttachments, chachaKey = entity.chachaKey, senderPublicKey = senderKey, senderName = senderName @@ -1464,6 +1476,111 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } + private fun parseAttachmentType(attachment: JSONObject): AttachmentType { + val rawType = attachment.opt("type") + val typeValue = + when (rawType) { + is Number -> rawType.toInt() + is String -> { + val normalized = rawType.trim() + normalized.toIntOrNull() + ?: when (normalized.lowercase(Locale.ROOT)) { + "image" -> AttachmentType.IMAGE.value + "messages", "reply", "forward" -> AttachmentType.MESSAGES.value + "file" -> AttachmentType.FILE.value + "avatar" -> AttachmentType.AVATAR.value + "call" -> AttachmentType.CALL.value + else -> -1 + } + } + else -> -1 + } + return AttachmentType.fromInt(typeValue) + } + + private fun isLikelyCallAttachmentPreview(preview: String): Boolean { + val normalized = preview.trim() + if (normalized.isEmpty()) return true + + val tail = normalized.substringAfterLast("::", normalized).trim() + if (tail.toIntOrNull() != null) return true + + return Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE) + .containsMatchIn(normalized) + } + + private fun normalizeIncomingCallAttachments( + attachments: List, + messageText: String + ): List { + if (attachments.isEmpty() || messageText.isNotBlank() || attachments.size != 1) { + return attachments + } + + val first = attachments.first() + if (first.blob.isNotBlank() || first.width > 0 || first.height > 0) { + return attachments + } + if (!isLikelyCallAttachmentPreview(first.preview)) { + return attachments + } + + return when (first.type) { + AttachmentType.CALL -> attachments + else -> listOf(first.copy(type = AttachmentType.CALL)) + } + } + + private fun parseCallAttachmentFallback( + attachmentsJson: String, + fallbackId: String + ): MessageAttachment? { + val array = parseAttachmentsJsonArray(attachmentsJson) ?: return null + if (array.length() != 1) return null + val first = array.optJSONObject(0) ?: return null + + if (first.optString("blob", "").isNotBlank()) return null + if (first.optInt("width", 0) > 0 || first.optInt("height", 0) > 0) return null + + val preview = first.optString("preview", "") + if (!isLikelyCallAttachmentPreview(preview)) return null + + return MessageAttachment( + id = first.optString("id", "").ifBlank { "call-$fallbackId" }, + blob = "", + type = AttachmentType.CALL, + preview = preview, + width = 0, + height = 0 + ) + } + + private fun parseAttachmentsJsonArray(attachmentsJson: String): JSONArray? { + val normalized = attachmentsJson.trim() + if (normalized.isEmpty() || normalized == "[]") return null + + val parsedDirectArray = runCatching { JSONArray(normalized) }.getOrNull() + if (parsedDirectArray != null) return parsedDirectArray + + val parsedDirectObject = runCatching { JSONObject(normalized) }.getOrNull() + if (parsedDirectObject != null) { + return JSONArray().put(parsedDirectObject) + } + + if (normalized.length >= 2 && normalized.first() == '"' && normalized.last() == '"') { + val unescaped = + runCatching { JSONObject("{\"v\":$normalized}").optString("v", "") } + .getOrDefault("") + .trim() + if (unescaped.isNotEmpty() && unescaped != normalized) { + runCatching { JSONArray(unescaped) }.getOrNull()?.let { return it } + runCatching { JSONObject(unescaped) }.getOrNull()?.let { return JSONArray().put(it) } + } + } + + return null + } + /** * Парсинг всех attachments из JSON (кроме MESSAGES который обрабатывается отдельно) 💾 Для * IMAGE - загружает blob из файловой системы если пустой в БД @@ -1474,25 +1591,37 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } return try { - val attachments = JSONArray(attachmentsJson) + val attachments = parseAttachmentsJsonArray(attachmentsJson) ?: return emptyList() val result = mutableListOf() val publicKey = myPublicKey ?: "" val privateKey = myPrivateKey ?: "" for (i in 0 until attachments.length()) { val attachment = attachments.getJSONObject(i) - val type = attachment.optInt("type", 0) + val attachmentType = parseAttachmentType(attachment) + val preview = attachment.optString("preview", "") + val hasBlob = attachment.optString("blob", "").isNotBlank() + val hasSize = attachment.optInt("width", 0) > 0 || attachment.optInt("height", 0) > 0 + val effectiveType = + if (attachmentType == AttachmentType.MESSAGES && + !hasBlob && + !hasSize && + isLikelyCallAttachmentPreview(preview) + ) { + AttachmentType.CALL + } else { + attachmentType + } // Пропускаем MESSAGES (1) - это reply, обрабатывается отдельно - if (type == 1) continue + if (effectiveType == AttachmentType.MESSAGES) continue var blob = attachment.optString("blob", "") val attachmentId = attachment.optString("id", "") - val attachmentType = AttachmentType.fromInt(type) // 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой - if ((attachmentType == AttachmentType.IMAGE || - attachmentType == AttachmentType.AVATAR) && + if ((effectiveType == AttachmentType.IMAGE || + effectiveType == AttachmentType.AVATAR) && blob.isEmpty() && attachmentId.isNotEmpty() ) { @@ -1512,8 +1641,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { MessageAttachment( id = attachmentId, blob = blob, - type = attachmentType, - preview = attachment.optString("preview", ""), + type = effectiveType, + preview = preview, width = attachment.optInt("width", 0), height = attachment.optInt("height", 0), localUri = @@ -4755,10 +4884,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private fun resolvePrimaryAttachmentTypeFromJson(attachmentsJson: String): Int { if (attachmentsJson.isBlank() || attachmentsJson == "[]") return -1 return try { - val array = JSONArray(attachmentsJson) + val array = parseAttachmentsJsonArray(attachmentsJson) ?: return -1 if (array.length() == 0) return -1 val first = array.optJSONObject(0) ?: return -1 - first.optInt("type", -1) + val parsedType = parseAttachmentType(first) + if (parsedType != AttachmentType.UNKNOWN) { + parsedType.value + } else if (array.length() == 1 && + isLikelyCallAttachmentPreview(first.optString("preview", "")) + ) { + AttachmentType.CALL.value + } else { + -1 + } } catch (_: Throwable) { -1 } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 1377898..3b7bd4a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -14,12 +14,15 @@ import com.rosetta.messenger.network.PacketOnlineSubscribe import com.rosetta.messenger.network.PacketSearch import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser +import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject /** UI модель диалога с расшифрованным lastMessage */ @Immutable @@ -303,7 +306,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio val attachmentType = resolveAttachmentType( attachmentType = dialog.lastMessageAttachmentType, - decryptedLastMessage = decryptedLastMessage + decryptedLastMessage = decryptedLastMessage, + lastMessageAttachments = dialog.lastMessageAttachments ) val groupLastSenderInfo = resolveGroupLastSenderInfo(dialog) @@ -543,10 +547,12 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio private fun resolveAttachmentType( attachmentType: Int, - decryptedLastMessage: String + decryptedLastMessage: String, + lastMessageAttachments: String ): String? { + val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments) return when (attachmentType) { - 0 -> "Photo" // AttachmentType.IMAGE = 0 + 0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0 1 -> { // AttachmentType.MESSAGES = 1 (Reply/Forward). // Если текст пустой — показываем "Forwarded" как в desktop. @@ -555,10 +561,66 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio 2 -> "File" // AttachmentType.FILE = 2 3 -> "Avatar" // AttachmentType.AVATAR = 3 4 -> "Call" // AttachmentType.CALL = 4 - else -> null + else -> if (inferredCall) "Call" else null } } + private fun isLikelyCallAttachmentJson(rawAttachments: String): Boolean { + if (rawAttachments.isBlank() || rawAttachments == "[]") return false + return try { + val attachments = parseAttachmentsJsonArray(rawAttachments) ?: return false + if (attachments.length() != 1) return false + val first = attachments.optJSONObject(0) ?: return false + + val rawType = first.opt("type") + val typeValue = + when (rawType) { + is Number -> rawType.toInt() + is String -> { + val normalized = rawType.trim() + normalized.toIntOrNull() + ?: when (normalized.lowercase(Locale.ROOT)) { + "call" -> 4 + else -> -1 + } + } + else -> -1 + } + if (typeValue == 4) return true + + val preview = first.optString("preview", "").trim() + if (preview.isEmpty()) return true + val tail = preview.substringAfterLast("::", preview).trim() + if (tail.toIntOrNull() != null) return true + + Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE) + .containsMatchIn(preview) + } catch (_: Throwable) { + false + } + } + + private fun parseAttachmentsJsonArray(rawAttachments: String): JSONArray? { + val normalized = rawAttachments.trim() + if (normalized.isEmpty() || normalized == "[]") return null + + runCatching { JSONArray(normalized) }.getOrNull()?.let { return it } + runCatching { JSONObject(normalized) }.getOrNull()?.let { return JSONArray().put(it) } + + if (normalized.length >= 2 && normalized.first() == '"' && normalized.last() == '"') { + val unescaped = + runCatching { JSONObject("{\"v\":$normalized}").optString("v", "") } + .getOrDefault("") + .trim() + if (unescaped.isNotEmpty() && unescaped != normalized) { + runCatching { JSONArray(unescaped) }.getOrNull()?.let { return it } + runCatching { JSONObject(unescaped) }.getOrNull()?.let { return JSONArray().put(it) } + } + } + + return null + } + private fun isLikelyEncryptedPayload(value: String): Boolean { if (value.startsWith("CHNK:")) return true diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 28d2e72..d85acb8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -530,9 +530,7 @@ fun MessageAttachments( CallAttachment( attachment = attachment, isOutgoing = isOutgoing, - isDarkTheme = isDarkTheme, - timestamp = timestamp, - messageStatus = messageStatus + isDarkTheme = isDarkTheme ) } else -> { @@ -1593,7 +1591,7 @@ private fun resolveDesktopCallUi(preview: String, isOutgoing: Boolean): DesktopC } val subtitle = if (isError) { - if (isOutgoing) "Rejected" else "Missed" + "Call was not answered or was rejected" } else { formatDesktopCallDuration(durationSec) } @@ -1605,9 +1603,7 @@ private fun resolveDesktopCallUi(preview: String, isOutgoing: Boolean): DesktopC fun CallAttachment( attachment: MessageAttachment, isOutgoing: Boolean, - isDarkTheme: Boolean, - timestamp: java.util.Date, - messageStatus: MessageStatus = MessageStatus.READ + isDarkTheme: Boolean ) { val callUi = remember(attachment.preview, isOutgoing) { resolveDesktopCallUi(attachment.preview, isOutgoing)