diff --git a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt index 57eaf0b..71d1d8f 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -662,21 +662,145 @@ object MessageCrypto { /** * Converts bytes to string mimicking JavaScript's Buffer.toString('utf-8') behavior. - * Invalid UTF-8 sequences are replaced with U+FFFD (replacement character). + * Uses WHATWG UTF-8 decoder algorithm where each invalid byte produces exactly ONE U+FFFD. + * This is critical for cross-platform compatibility with React Native! */ private fun bytesToJsUtf8String(bytes: ByteArray): String { - // CharsetDecoder with REPLACE action mimics JS behavior - val decoder = Charsets.UTF_8.newDecoder() - decoder.onMalformedInput(java.nio.charset.CodingErrorAction.REPLACE) - decoder.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPLACE) - decoder.replaceWith("\uFFFD") + val result = StringBuilder() + var i = 0 - return try { - decoder.decode(java.nio.ByteBuffer.wrap(bytes)).toString() - } catch (e: Exception) { - // Fallback to standard UTF-8 (shouldn't happen with REPLACE action) - String(bytes, Charsets.UTF_8) + while (i < bytes.size) { + val b0 = bytes[i].toInt() and 0xFF + + when { + // ASCII (0x00-0x7F) - single byte + b0 <= 0x7F -> { + result.append(b0.toChar()) + i++ + } + + // Continuation byte without starter (0x80-0xBF) - invalid + b0 <= 0xBF -> { + result.append('\uFFFD') + i++ + } + + // 2-byte sequence (0xC0-0xDF) + b0 <= 0xDF -> { + if (i + 1 >= bytes.size) { + // Truncated - emit replacement for this byte + result.append('\uFFFD') + i++ + } else { + val b1 = bytes[i + 1].toInt() and 0xFF + if (b1 and 0xC0 != 0x80) { + // Invalid continuation - emit replacement for starter only + result.append('\uFFFD') + i++ + } else { + val codePoint = ((b0 and 0x1F) shl 6) or (b1 and 0x3F) + // Check for overlong encoding (should be >= 0x80 for 2-byte) + if (codePoint < 0x80 || b0 == 0xC0 || b0 == 0xC1) { + // Overlong - emit replacement for each byte + result.append('\uFFFD') + result.append('\uFFFD') + } else { + result.append(codePoint.toChar()) + } + i += 2 + } + } + } + + // 3-byte sequence (0xE0-0xEF) + b0 <= 0xEF -> { + if (i + 2 >= bytes.size) { + // Truncated + val remaining = bytes.size - i + repeat(remaining) { result.append('\uFFFD') } + i = bytes.size + } else { + val b1 = bytes[i + 1].toInt() and 0xFF + val b2 = bytes[i + 2].toInt() and 0xFF + + if (b1 and 0xC0 != 0x80) { + // Invalid first continuation + result.append('\uFFFD') + i++ + } else if (b2 and 0xC0 != 0x80) { + // Invalid second continuation - emit for first two bytes + result.append('\uFFFD') + result.append('\uFFFD') + i += 2 + } else { + val codePoint = ((b0 and 0x0F) shl 12) or ((b1 and 0x3F) shl 6) or (b2 and 0x3F) + // Check for overlong (should be >= 0x800 for 3-byte) and surrogates + if (codePoint < 0x800 || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) { + // Invalid - emit replacement for each byte + result.append('\uFFFD') + result.append('\uFFFD') + result.append('\uFFFD') + } else { + result.append(codePoint.toChar()) + } + i += 3 + } + } + } + + // 4-byte sequence (0xF0-0xF7) + b0 <= 0xF7 -> { + if (i + 3 >= bytes.size) { + // Truncated + val remaining = bytes.size - i + repeat(remaining) { result.append('\uFFFD') } + i = bytes.size + } else { + val b1 = bytes[i + 1].toInt() and 0xFF + val b2 = bytes[i + 2].toInt() and 0xFF + val b3 = bytes[i + 3].toInt() and 0xFF + + if (b1 and 0xC0 != 0x80) { + result.append('\uFFFD') + i++ + } else if (b2 and 0xC0 != 0x80) { + result.append('\uFFFD') + result.append('\uFFFD') + i += 2 + } else if (b3 and 0xC0 != 0x80) { + result.append('\uFFFD') + result.append('\uFFFD') + result.append('\uFFFD') + i += 3 + } else { + val codePoint = ((b0 and 0x07) shl 18) or ((b1 and 0x3F) shl 12) or + ((b2 and 0x3F) shl 6) or (b3 and 0x3F) + // Check for overlong (should be >= 0x10000) and max Unicode + if (codePoint < 0x10000 || codePoint > 0x10FFFF) { + result.append('\uFFFD') + result.append('\uFFFD') + result.append('\uFFFD') + result.append('\uFFFD') + } else { + // Encode as surrogate pair + val adjusted = codePoint - 0x10000 + result.append((0xD800 + (adjusted shr 10)).toChar()) + result.append((0xDC00 + (adjusted and 0x3FF)).toChar()) + } + i += 4 + } + } + } + + // Invalid starter byte (0xF8-0xFF) + else -> { + result.append('\uFFFD') + i++ + } + } } + + return result.toString() } /** 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 de616d5..40bdcb5 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -284,9 +284,6 @@ class MessageRepository private constructor(private val context: Context) { privateKey ) - // Извлекаем replyToMessageId из attachments - val replyToMessageId = extractReplyToMessageId(packet.attachments) - // 🔒 Шифруем plainMessage с использованием приватного ключа val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey) @@ -304,7 +301,6 @@ class MessageRepository private constructor(private val context: Context) { messageId = messageId, // 🔥 Используем сгенерированный messageId! plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст attachments = attachmentsJson, - replyToMessageId = replyToMessageId, // 🔥 Добавляем replyToMessageId из attachments! dialogKey = dialogKey ) @@ -556,32 +552,6 @@ class MessageRepository private constructor(private val context: Context) { plainMessage } - // Десериализуем attachments из JSON - val attachmentsList = try { - if (attachments.isNotEmpty() && attachments != "[]") { - val jsonArray = JSONArray(attachments) - (0 until jsonArray.length()).mapNotNull { i -> - try { - val obj = jsonArray.getJSONObject(i) - MessageAttachment( - id = obj.optString("id", ""), - blob = obj.optString("blob", ""), - type = AttachmentType.fromInt(obj.optInt("type", 0)), - preview = obj.optString("preview", ""), - width = obj.optInt("width", 0), - height = obj.optInt("height", 0) - ) - } catch (e: Exception) { - null - } - } - } else { - emptyList() - } - } catch (e: Exception) { - emptyList() - } - return Message( id = id, messageId = messageId, @@ -592,7 +562,6 @@ class MessageRepository private constructor(private val context: Context) { isFromMe = fromMe == 1, isRead = read == 1, deliveryStatus = DeliveryStatus.fromInt(delivered), - attachments = attachmentsList, // Десериализованные attachments replyToMessageId = replyToMessageId ) } @@ -630,39 +599,6 @@ class MessageRepository private constructor(private val context: Context) { return jsonArray.toString() } - /** - * Извлечение replyToMessageId из attachments - * Ищет MESSAGES (type=1) attachment и парсит message_id - */ - private fun extractReplyToMessageId(attachments: List): String? { - if (attachments.isEmpty()) return null - - for (attachment in attachments) { - if (attachment.type == AttachmentType.MESSAGES) { - try { - // Берем blob или preview - val dataJson = attachment.blob.ifEmpty { attachment.preview } - if (dataJson.isEmpty() || dataJson.contains(":")) { - // Пропускаем зашифрованные старые сообщения - continue - } - - val messagesArray = JSONArray(dataJson) - if (messagesArray.length() > 0) { - val replyMessage = messagesArray.getJSONObject(0) - val replyMessageId = replyMessage.optString("message_id", "") - if (replyMessageId.isNotEmpty()) { - return replyMessageId - } - } - } catch (e: Exception) { - e.printStackTrace() - } - } - } - return null - } - /** * Сериализация attachments в JSON с расшифровкой MESSAGES blob * Для MESSAGES типа blob расшифровывается и сохраняется в preview (как в RN)