Улучшена обработка звонков и вложений: нормализация входящих аттачментов, обновление UI карточек звонков
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<MessageAttachment>,
|
||||
plainText: String
|
||||
): List<MessageAttachment> {
|
||||
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()
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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 = ""
|
||||
)
|
||||
|
||||
@@ -15,29 +15,41 @@ class PacketMessage : Packet() {
|
||||
var aesChachaKey: String = "" // ChaCha key+nonce зашифрованный приватным ключом отправителя
|
||||
var attachments: List<MessageAttachment> = 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<MessageAttachment>,
|
||||
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<MessageAttachment>()
|
||||
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<MessageAttachment>(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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<MessageAttachment>,
|
||||
messageText: String
|
||||
): List<MessageAttachment> {
|
||||
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<MessageAttachment>()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user