Улучшена обработка звонков и вложений: нормализация входящих аттачментов, обновление 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: Расшифровка успешна
|
// 📝 LOG: Расшифровка успешна
|
||||||
MessageLogger.logDecryptionSuccess(
|
MessageLogger.logDecryptionSuccess(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
plainTextLength = plainText.length,
|
plainTextLength = plainText.length,
|
||||||
attachmentsCount = packet.attachments.size
|
attachmentsCount = normalizedIncomingAttachments.size
|
||||||
)
|
)
|
||||||
|
|
||||||
// Сериализуем attachments в JSON с расшифровкой MESSAGES blob
|
// Сериализуем attachments в JSON с расшифровкой MESSAGES blob
|
||||||
val attachmentsJson =
|
val attachmentsJson =
|
||||||
serializeAttachmentsWithDecryption(
|
serializeAttachmentsWithDecryption(
|
||||||
packet.attachments,
|
normalizedIncomingAttachments,
|
||||||
packet.chachaKey,
|
packet.chachaKey,
|
||||||
privateKey,
|
privateKey,
|
||||||
plainKeyAndNonce,
|
plainKeyAndNonce,
|
||||||
@@ -863,7 +866,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
|
|
||||||
// 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop)
|
// 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop)
|
||||||
processImageAttachments(
|
processImageAttachments(
|
||||||
packet.attachments,
|
normalizedIncomingAttachments,
|
||||||
packet.chachaKey,
|
packet.chachaKey,
|
||||||
privateKey,
|
privateKey,
|
||||||
plainKeyAndNonce,
|
plainKeyAndNonce,
|
||||||
@@ -876,7 +879,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
val avatarOwnerKey =
|
val avatarOwnerKey =
|
||||||
if (isGroupMessage) toGroupDialogPublicKey(packet.toPublicKey) else packet.fromPublicKey
|
if (isGroupMessage) toGroupDialogPublicKey(packet.toPublicKey) else packet.fromPublicKey
|
||||||
processAvatarAttachments(
|
processAvatarAttachments(
|
||||||
packet.attachments,
|
normalizedIncomingAttachments,
|
||||||
avatarOwnerKey,
|
avatarOwnerKey,
|
||||||
packet.chachaKey,
|
packet.chachaKey,
|
||||||
privateKey,
|
privateKey,
|
||||||
@@ -917,7 +920,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
||||||
attachments = attachmentsJson,
|
attachments = attachmentsJson,
|
||||||
primaryAttachmentType =
|
primaryAttachmentType =
|
||||||
resolvePrimaryAttachmentType(packet.attachments),
|
resolvePrimaryAttachmentType(normalizedIncomingAttachments),
|
||||||
dialogKey = dialogKey
|
dialogKey = dialogKey
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1703,6 +1706,50 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
return attachments.first().type.value
|
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) {
|
private suspend fun upsertSearchIndex(account: String, entity: MessageEntity, plainText: String) {
|
||||||
val opponentKey =
|
val opponentKey =
|
||||||
if (entity.fromMe == 1) entity.toPublicKey.trim() else entity.fromPublicKey.trim()
|
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 TAIL_LINES = 300
|
||||||
private const val PROTOCOL_LOG_TAIL_LINES = 180
|
private const val PROTOCOL_LOG_TAIL_LINES = 180
|
||||||
private const val MAX_LOG_PREFIX = 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 scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private val secureRandom = SecureRandom()
|
private val secureRandom = SecureRandom()
|
||||||
@@ -123,6 +124,7 @@ object CallManager {
|
|||||||
private var durationJob: Job? = null
|
private var durationJob: Job? = null
|
||||||
private var protocolStateJob: Job? = null
|
private var protocolStateJob: Job? = null
|
||||||
private var disconnectResetJob: Job? = null
|
private var disconnectResetJob: Job? = null
|
||||||
|
private var incomingRingTimeoutJob: Job? = null
|
||||||
|
|
||||||
private var signalWaiter: ((Packet) -> Unit)? = null
|
private var signalWaiter: ((Packet) -> Unit)? = null
|
||||||
private var webRtcWaiter: ((Packet) -> Unit)? = null
|
private var webRtcWaiter: ((Packet) -> Unit)? = null
|
||||||
@@ -231,6 +233,8 @@ object CallManager {
|
|||||||
sharedPublic = localPublic.toHex()
|
sharedPublic = localPublic.toHex()
|
||||||
)
|
)
|
||||||
keyExchangeSent = true
|
keyExchangeSent = true
|
||||||
|
incomingRingTimeoutJob?.cancel()
|
||||||
|
incomingRingTimeoutJob = null
|
||||||
|
|
||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -245,6 +249,8 @@ object CallManager {
|
|||||||
fun declineIncomingCall() {
|
fun declineIncomingCall() {
|
||||||
val snapshot = _state.value
|
val snapshot = _state.value
|
||||||
if (snapshot.phase != CallPhase.INCOMING) return
|
if (snapshot.phase != CallPhase.INCOMING) return
|
||||||
|
incomingRingTimeoutJob?.cancel()
|
||||||
|
incomingRingTimeoutJob = null
|
||||||
if (ownPublicKey.isNotBlank() && snapshot.peerPublicKey.isNotBlank()) {
|
if (ownPublicKey.isNotBlank() && snapshot.peerPublicKey.isNotBlank()) {
|
||||||
ProtocolManager.sendCallSignal(
|
ProtocolManager.sendCallSignal(
|
||||||
signalType = SignalType.END_CALL,
|
signalType = SignalType.END_CALL,
|
||||||
@@ -287,19 +293,34 @@ object CallManager {
|
|||||||
|
|
||||||
private suspend fun handleSignalPacket(packet: PacketSignalPeer) {
|
private suspend fun handleSignalPacket(packet: PacketSignalPeer) {
|
||||||
breadcrumb("SIG: ${packet.signalType} from=${packet.src.take(8)}… phase=${_state.value.phase}")
|
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) {
|
when (packet.signalType) {
|
||||||
SignalType.END_CALL_BECAUSE_BUSY -> {
|
SignalType.END_CALL_BECAUSE_BUSY -> {
|
||||||
|
if (currentPhase == CallPhase.IDLE) {
|
||||||
|
breadcrumb("SIG: peer busy IGNORED — no active call")
|
||||||
|
return
|
||||||
|
}
|
||||||
breadcrumb("SIG: peer busy → reset")
|
breadcrumb("SIG: peer busy → reset")
|
||||||
resetSession(reason = "User is busy", notifyPeer = false)
|
resetSession(reason = "User is busy", notifyPeer = false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED -> {
|
SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED -> {
|
||||||
|
if (currentPhase == CallPhase.IDLE) {
|
||||||
|
breadcrumb("SIG: peer disconnected IGNORED — no active call")
|
||||||
|
return
|
||||||
|
}
|
||||||
breadcrumb("SIG: peer disconnected → reset")
|
breadcrumb("SIG: peer disconnected → reset")
|
||||||
resetSession(reason = "Peer disconnected", notifyPeer = false)
|
resetSession(reason = "Peer disconnected", notifyPeer = false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
SignalType.END_CALL -> {
|
SignalType.END_CALL -> {
|
||||||
|
if (currentPhase == CallPhase.IDLE) {
|
||||||
|
breadcrumb("SIG: END_CALL IGNORED — no active call")
|
||||||
|
return
|
||||||
|
}
|
||||||
breadcrumb("SIG: END_CALL → reset")
|
breadcrumb("SIG: END_CALL → reset")
|
||||||
resetSession(reason = "Call ended", notifyPeer = false)
|
resetSession(reason = "Call ended", notifyPeer = false)
|
||||||
return
|
return
|
||||||
@@ -307,8 +328,11 @@ object CallManager {
|
|||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentPeer = _state.value.peerPublicKey
|
|
||||||
val src = packet.src.trim()
|
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) {
|
if (currentPeer.isNotBlank() && src.isNotBlank() && src != currentPeer && src != ownPublicKey) {
|
||||||
breadcrumb("SIG: IGNORED (src mismatch: expected=${currentPeer.take(8)}… got=${src.take(8)}…)")
|
breadcrumb("SIG: IGNORED (src mismatch: expected=${currentPeer.take(8)}… got=${src.take(8)}…)")
|
||||||
return
|
return
|
||||||
@@ -343,6 +367,18 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) }
|
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) }
|
||||||
resolvePeerIdentity(incomingPeer)
|
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 -> {
|
SignalType.KEY_EXCHANGE -> {
|
||||||
breadcrumb("SIG: KEY_EXCHANGE → handleKeyExchange")
|
breadcrumb("SIG: KEY_EXCHANGE → handleKeyExchange")
|
||||||
@@ -833,6 +869,8 @@ object CallManager {
|
|||||||
durationJob = null
|
durationJob = null
|
||||||
disconnectResetJob?.cancel()
|
disconnectResetJob?.cancel()
|
||||||
disconnectResetJob = null
|
disconnectResetJob = null
|
||||||
|
incomingRingTimeoutJob?.cancel()
|
||||||
|
incomingRingTimeoutJob = null
|
||||||
setSpeakerphone(false)
|
setSpeakerphone(false)
|
||||||
_state.value = CallUiState()
|
_state.value = CallUiState()
|
||||||
}
|
}
|
||||||
@@ -1254,7 +1292,19 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateState(reducer: (CallUiState) -> CallUiState) {
|
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) }
|
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||||
|
|||||||
@@ -10,5 +10,9 @@ data class MessageAttachment(
|
|||||||
val preview: String = "", // Метаданные: "UUID::metadata" или "filesize::filename"
|
val preview: String = "", // Метаданные: "UUID::metadata" или "filesize::filename"
|
||||||
val width: Int = 0,
|
val width: Int = 0,
|
||||||
val height: 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 aesChachaKey: String = "" // ChaCha key+nonce зашифрованный приватным ключом отправителя
|
||||||
var attachments: List<MessageAttachment> = emptyList()
|
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 getPacketId(): Int = 0x06
|
||||||
|
|
||||||
override fun receive(stream: Stream) {
|
override fun receive(stream: Stream) {
|
||||||
fromPublicKey = stream.readString()
|
val startPointer = stream.getReadPointerBits()
|
||||||
toPublicKey = stream.readString()
|
val extended = parseFromStream(stream, readExtendedAttachmentMeta = true)
|
||||||
content = stream.readString()
|
val parsed =
|
||||||
chachaKey = stream.readString()
|
if (extended != null && !stream.hasRemainingBits()) {
|
||||||
timestamp = stream.readInt64()
|
extended
|
||||||
privateKey = stream.readString()
|
} else {
|
||||||
messageId = stream.readString()
|
stream.setReadPointerBits(startPointer)
|
||||||
|
parseFromStream(stream, readExtendedAttachmentMeta = false)
|
||||||
|
?: throw IllegalStateException("Failed to parse PacketMessage payload")
|
||||||
|
}
|
||||||
|
|
||||||
val attachmentCount = stream.readInt8()
|
fromPublicKey = parsed.fromPublicKey
|
||||||
val attachmentsList = mutableListOf<MessageAttachment>()
|
toPublicKey = parsed.toPublicKey
|
||||||
for (i in 0 until attachmentCount) {
|
content = parsed.content
|
||||||
attachmentsList.add(MessageAttachment(
|
chachaKey = parsed.chachaKey
|
||||||
id = stream.readString(),
|
timestamp = parsed.timestamp
|
||||||
preview = stream.readString(),
|
privateKey = parsed.privateKey
|
||||||
blob = stream.readString(),
|
messageId = parsed.messageId
|
||||||
type = AttachmentType.fromInt(stream.readInt8())
|
attachments = parsed.attachments
|
||||||
))
|
aesChachaKey = parsed.aesChachaKey
|
||||||
}
|
|
||||||
attachments = attachmentsList
|
|
||||||
aesChachaKey = stream.readString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun send(): Stream {
|
override fun send(): Stream {
|
||||||
@@ -62,4 +74,70 @@ class PacketMessage : Packet() {
|
|||||||
|
|
||||||
return stream
|
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 getReadPointerBits(): Int = _readPointer
|
||||||
|
|
||||||
|
fun setReadPointerBits(bits: Int) {
|
||||||
|
_readPointer = bits.coerceIn(0, getTotalBits())
|
||||||
|
}
|
||||||
|
|
||||||
fun getTotalBits(): Int = _stream.size * 8
|
fun getTotalBits(): Int = _stream.size * 8
|
||||||
|
|
||||||
fun getRemainingBits(): Int = getTotalBits() - _readPointer
|
fun getRemainingBits(): Int = getTotalBits() - _readPointer
|
||||||
|
|||||||
@@ -1314,7 +1314,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
} else {}
|
} else {}
|
||||||
|
|
||||||
// Парсим все attachments (IMAGE, FILE, AVATAR)
|
// Парсим все 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 myKey = myPublicKey.orEmpty().trim()
|
||||||
val senderKey = entity.fromPublicKey.trim()
|
val senderKey = entity.fromPublicKey.trim()
|
||||||
@@ -1345,7 +1357,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
},
|
},
|
||||||
replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
|
replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
|
||||||
forwardedMessages = forwardedMessages,
|
forwardedMessages = forwardedMessages,
|
||||||
attachments = parsedAttachments,
|
attachments = finalAttachments,
|
||||||
chachaKey = entity.chachaKey,
|
chachaKey = entity.chachaKey,
|
||||||
senderPublicKey = senderKey,
|
senderPublicKey = senderKey,
|
||||||
senderName = senderName
|
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 который обрабатывается отдельно) 💾 Для
|
* Парсинг всех attachments из JSON (кроме MESSAGES который обрабатывается отдельно) 💾 Для
|
||||||
* IMAGE - загружает blob из файловой системы если пустой в БД
|
* IMAGE - загружает blob из файловой системы если пустой в БД
|
||||||
@@ -1474,25 +1591,37 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val attachments = JSONArray(attachmentsJson)
|
val attachments = parseAttachmentsJsonArray(attachmentsJson) ?: return emptyList()
|
||||||
val result = mutableListOf<MessageAttachment>()
|
val result = mutableListOf<MessageAttachment>()
|
||||||
val publicKey = myPublicKey ?: ""
|
val publicKey = myPublicKey ?: ""
|
||||||
val privateKey = myPrivateKey ?: ""
|
val privateKey = myPrivateKey ?: ""
|
||||||
|
|
||||||
for (i in 0 until attachments.length()) {
|
for (i in 0 until attachments.length()) {
|
||||||
val attachment = attachments.getJSONObject(i)
|
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, обрабатывается отдельно
|
// Пропускаем MESSAGES (1) - это reply, обрабатывается отдельно
|
||||||
if (type == 1) continue
|
if (effectiveType == AttachmentType.MESSAGES) continue
|
||||||
|
|
||||||
var blob = attachment.optString("blob", "")
|
var blob = attachment.optString("blob", "")
|
||||||
val attachmentId = attachment.optString("id", "")
|
val attachmentId = attachment.optString("id", "")
|
||||||
val attachmentType = AttachmentType.fromInt(type)
|
|
||||||
|
|
||||||
// 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой
|
// 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой
|
||||||
if ((attachmentType == AttachmentType.IMAGE ||
|
if ((effectiveType == AttachmentType.IMAGE ||
|
||||||
attachmentType == AttachmentType.AVATAR) &&
|
effectiveType == AttachmentType.AVATAR) &&
|
||||||
blob.isEmpty() &&
|
blob.isEmpty() &&
|
||||||
attachmentId.isNotEmpty()
|
attachmentId.isNotEmpty()
|
||||||
) {
|
) {
|
||||||
@@ -1512,8 +1641,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
MessageAttachment(
|
MessageAttachment(
|
||||||
id = attachmentId,
|
id = attachmentId,
|
||||||
blob = blob,
|
blob = blob,
|
||||||
type = attachmentType,
|
type = effectiveType,
|
||||||
preview = attachment.optString("preview", ""),
|
preview = preview,
|
||||||
width = attachment.optInt("width", 0),
|
width = attachment.optInt("width", 0),
|
||||||
height = attachment.optInt("height", 0),
|
height = attachment.optInt("height", 0),
|
||||||
localUri =
|
localUri =
|
||||||
@@ -4755,10 +4884,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private fun resolvePrimaryAttachmentTypeFromJson(attachmentsJson: String): Int {
|
private fun resolvePrimaryAttachmentTypeFromJson(attachmentsJson: String): Int {
|
||||||
if (attachmentsJson.isBlank() || attachmentsJson == "[]") return -1
|
if (attachmentsJson.isBlank() || attachmentsJson == "[]") return -1
|
||||||
return try {
|
return try {
|
||||||
val array = JSONArray(attachmentsJson)
|
val array = parseAttachmentsJsonArray(attachmentsJson) ?: return -1
|
||||||
if (array.length() == 0) return -1
|
if (array.length() == 0) return -1
|
||||||
val first = array.optJSONObject(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) {
|
} catch (_: Throwable) {
|
||||||
-1
|
-1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ import com.rosetta.messenger.network.PacketOnlineSubscribe
|
|||||||
import com.rosetta.messenger.network.PacketSearch
|
import com.rosetta.messenger.network.PacketSearch
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
|
import java.util.Locale
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
/** UI модель диалога с расшифрованным lastMessage */
|
/** UI модель диалога с расшифрованным lastMessage */
|
||||||
@Immutable
|
@Immutable
|
||||||
@@ -303,7 +306,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
val attachmentType =
|
val attachmentType =
|
||||||
resolveAttachmentType(
|
resolveAttachmentType(
|
||||||
attachmentType = dialog.lastMessageAttachmentType,
|
attachmentType = dialog.lastMessageAttachmentType,
|
||||||
decryptedLastMessage = decryptedLastMessage
|
decryptedLastMessage = decryptedLastMessage,
|
||||||
|
lastMessageAttachments = dialog.lastMessageAttachments
|
||||||
)
|
)
|
||||||
val groupLastSenderInfo = resolveGroupLastSenderInfo(dialog)
|
val groupLastSenderInfo = resolveGroupLastSenderInfo(dialog)
|
||||||
|
|
||||||
@@ -543,10 +547,12 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
|
|
||||||
private fun resolveAttachmentType(
|
private fun resolveAttachmentType(
|
||||||
attachmentType: Int,
|
attachmentType: Int,
|
||||||
decryptedLastMessage: String
|
decryptedLastMessage: String,
|
||||||
|
lastMessageAttachments: String
|
||||||
): String? {
|
): String? {
|
||||||
|
val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments)
|
||||||
return when (attachmentType) {
|
return when (attachmentType) {
|
||||||
0 -> "Photo" // AttachmentType.IMAGE = 0
|
0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0
|
||||||
1 -> {
|
1 -> {
|
||||||
// AttachmentType.MESSAGES = 1 (Reply/Forward).
|
// AttachmentType.MESSAGES = 1 (Reply/Forward).
|
||||||
// Если текст пустой — показываем "Forwarded" как в desktop.
|
// Если текст пустой — показываем "Forwarded" как в desktop.
|
||||||
@@ -555,10 +561,66 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
2 -> "File" // AttachmentType.FILE = 2
|
2 -> "File" // AttachmentType.FILE = 2
|
||||||
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
||||||
4 -> "Call" // AttachmentType.CALL = 4
|
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 {
|
private fun isLikelyEncryptedPayload(value: String): Boolean {
|
||||||
if (value.startsWith("CHNK:")) return true
|
if (value.startsWith("CHNK:")) return true
|
||||||
|
|
||||||
|
|||||||
@@ -530,9 +530,7 @@ fun MessageAttachments(
|
|||||||
CallAttachment(
|
CallAttachment(
|
||||||
attachment = attachment,
|
attachment = attachment,
|
||||||
isOutgoing = isOutgoing,
|
isOutgoing = isOutgoing,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme
|
||||||
timestamp = timestamp,
|
|
||||||
messageStatus = messageStatus
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
@@ -1593,7 +1591,7 @@ private fun resolveDesktopCallUi(preview: String, isOutgoing: Boolean): DesktopC
|
|||||||
}
|
}
|
||||||
val subtitle =
|
val subtitle =
|
||||||
if (isError) {
|
if (isError) {
|
||||||
if (isOutgoing) "Rejected" else "Missed"
|
"Call was not answered or was rejected"
|
||||||
} else {
|
} else {
|
||||||
formatDesktopCallDuration(durationSec)
|
formatDesktopCallDuration(durationSec)
|
||||||
}
|
}
|
||||||
@@ -1605,9 +1603,7 @@ private fun resolveDesktopCallUi(preview: String, isOutgoing: Boolean): DesktopC
|
|||||||
fun CallAttachment(
|
fun CallAttachment(
|
||||||
attachment: MessageAttachment,
|
attachment: MessageAttachment,
|
||||||
isOutgoing: Boolean,
|
isOutgoing: Boolean,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean
|
||||||
timestamp: java.util.Date,
|
|
||||||
messageStatus: MessageStatus = MessageStatus.READ
|
|
||||||
) {
|
) {
|
||||||
val callUi = remember(attachment.preview, isOutgoing) {
|
val callUi = remember(attachment.preview, isOutgoing) {
|
||||||
resolveDesktopCallUi(attachment.preview, isOutgoing)
|
resolveDesktopCallUi(attachment.preview, isOutgoing)
|
||||||
|
|||||||
Reference in New Issue
Block a user