Улучшена обработка звонков и вложений: нормализация входящих аттачментов, обновление UI карточек звонков

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 20:45:25 +05:00
parent 434ccef30c
commit ff854e919e
8 changed files with 429 additions and 50 deletions

View File

@@ -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()

View File

@@ -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) }

View File

@@ -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 = ""
)

View File

@@ -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()
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)