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 9c0346b..16a5f38 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -637,6 +637,88 @@ object MessageCrypto { return plaintext } + + /** + * Расшифровка MESSAGES attachment blob + * Формат: ivBase64:ciphertextBase64 + * Использует PBKDF2 + AES-256-CBC + zlib decompression + */ + fun decryptAttachmentBlob( + encryptedData: String, + encryptedKey: String, + myPrivateKey: String + ): String? { + return try { + // 1. Расшифровываем ChaCha ключ (как для сообщений) + val keyAndNonce = decryptKeyFromSender(encryptedKey, myPrivateKey) + + // 2. Конвертируем key+nonce в строку (как в RN: key.toString('utf-8')) + val chachaKeyString = String(keyAndNonce, Charsets.UTF_8) + + // 3. Генерируем PBKDF2 ключ (salt='rosetta', 1000 iterations, sha256) + val pbkdf2Key = generatePBKDF2Key(chachaKeyString) + + // 4. Расшифровываем AES-256-CBC + decryptWithPBKDF2Key(encryptedData, pbkdf2Key) + } catch (e: Exception) { + android.util.Log.e("MessageCrypto", "❌ Failed to decrypt attachment blob", e) + null + } + } + + /** + * Генерация PBKDF2 ключа (совместимо с RN) + */ + private fun generatePBKDF2Key(password: String, salt: String = "rosetta", iterations: Int = 1000): ByteArray { + val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + val spec = javax.crypto.spec.PBEKeySpec( + password.toCharArray(), + salt.toByteArray(Charsets.UTF_8), + iterations, + 256 // 32 bytes + ) + return factory.generateSecret(spec).encoded + } + + /** + * Расшифровка с PBKDF2 ключом (AES-256-CBC + zlib) + * Формат: ivBase64:ciphertextBase64 + */ + private fun decryptWithPBKDF2Key(encryptedData: String, pbkdf2Key: ByteArray): String? { + return try { + val parts = encryptedData.split(":") + if (parts.size != 2) { + android.util.Log.e("MessageCrypto", "Invalid encrypted data format") + return null + } + + val iv = android.util.Base64.decode(parts[0], android.util.Base64.DEFAULT) + val ciphertext = android.util.Base64.decode(parts[1], android.util.Base64.DEFAULT) + + // AES-256-CBC расшифровка + val cipher = javax.crypto.Cipher.getInstance("AES/CBC/PKCS5Padding") + val secretKey = javax.crypto.spec.SecretKeySpec(pbkdf2Key, "AES") + val ivSpec = javax.crypto.spec.IvParameterSpec(iv) + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, secretKey, ivSpec) + val decrypted = cipher.doFinal(ciphertext) + + // Zlib декомпрессия + val inflater = java.util.zip.Inflater() + inflater.setInput(decrypted) + val outputStream = java.io.ByteArrayOutputStream() + val buffer = ByteArray(1024) + while (!inflater.finished()) { + val count = inflater.inflate(buffer) + outputStream.write(buffer, 0, count) + } + inflater.end() + + String(outputStream.toByteArray(), Charsets.UTF_8) + } catch (e: Exception) { + android.util.Log.e("MessageCrypto", "❌ Failed to decrypt with PBKDF2 key", e) + null + } + } } // Extension functions для конвертации 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 f4845c9..17cd0e5 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -7,6 +7,8 @@ import com.rosetta.messenger.database.* import com.rosetta.messenger.network.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +import org.json.JSONArray +import org.json.JSONObject import java.util.UUID /** @@ -152,6 +154,9 @@ class MessageRepository private constructor(private val context: Context) { toPublicKey ) + // Сериализуем attachments в JSON + val attachmentsJson = serializeAttachments(attachments) + // Сохраняем в БД val entity = MessageEntity( account = account, @@ -165,7 +170,7 @@ class MessageRepository private constructor(private val context: Context) { delivered = DeliveryStatus.WAITING.value, messageId = messageId, plainMessage = text.trim(), - attachments = "[]", // TODO: JSON serialize + attachments = attachmentsJson, replyToMessageId = replyToMessageId, dialogKey = dialogKey ) @@ -218,6 +223,13 @@ class MessageRepository private constructor(private val context: Context) { privateKey ) + // Сериализуем attachments в JSON с расшифровкой MESSAGES blob + val attachmentsJson = serializeAttachmentsWithDecryption( + packet.attachments, + packet.chachaKey, + privateKey + ) + // Сохраняем в БД val entity = MessageEntity( account = account, @@ -231,7 +243,7 @@ class MessageRepository private constructor(private val context: Context) { delivered = DeliveryStatus.DELIVERED.value, messageId = packet.messageId, plainMessage = plainText, - attachments = "[]", // TODO + attachments = attachmentsJson, dialogKey = dialogKey ) messageDao.insertMessage(entity) @@ -417,4 +429,92 @@ class MessageRepository private constructor(private val context: Context) { lastSeen = lastSeen, verified = verified == 1 ) + + /** + * Сериализация attachments в JSON + */ + private fun serializeAttachments(attachments: List): String { + if (attachments.isEmpty()) return "[]" + + val jsonArray = JSONArray() + for (attachment in attachments) { + val jsonObj = JSONObject().apply { + put("id", attachment.id) + put("blob", attachment.blob) + put("type", attachment.type.value) + put("preview", attachment.preview) + put("width", attachment.width) + put("height", attachment.height) + } + jsonArray.put(jsonObj) + } + return jsonArray.toString() + } + + /** + * Сериализация attachments в JSON с расшифровкой MESSAGES blob + * Для MESSAGES типа blob расшифровывается и сохраняется в preview (как в RN) + */ + private fun serializeAttachmentsWithDecryption( + attachments: List, + encryptedKey: String, + privateKey: String + ): String { + if (attachments.isEmpty()) return "[]" + + val jsonArray = JSONArray() + for (attachment in attachments) { + val jsonObj = JSONObject() + + // Для MESSAGES типа расшифровываем blob + if (attachment.type == AttachmentType.MESSAGES && attachment.blob.isNotEmpty()) { + try { + val decryptedBlob = MessageCrypto.decryptAttachmentBlob( + attachment.blob, + encryptedKey, + privateKey + ) + + if (decryptedBlob != null) { + // Сохраняем расшифрованный JSON в preview (как в RN) + jsonObj.put("id", attachment.id) + jsonObj.put("blob", decryptedBlob) // Расшифрованный JSON + jsonObj.put("type", attachment.type.value) + jsonObj.put("preview", decryptedBlob) // Для совместимости + jsonObj.put("width", attachment.width) + jsonObj.put("height", attachment.height) + android.util.Log.d("MessageRepository", "✅ Decrypted MESSAGES blob: ${decryptedBlob.take(100)}") + } else { + // Fallback - сохраняем как есть + jsonObj.put("id", attachment.id) + jsonObj.put("blob", attachment.blob) + jsonObj.put("type", attachment.type.value) + jsonObj.put("preview", attachment.preview) + jsonObj.put("width", attachment.width) + jsonObj.put("height", attachment.height) + } + } catch (e: Exception) { + android.util.Log.e("MessageRepository", "❌ Failed to decrypt MESSAGES blob", e) + // Fallback - сохраняем как есть + jsonObj.put("id", attachment.id) + jsonObj.put("blob", attachment.blob) + jsonObj.put("type", attachment.type.value) + jsonObj.put("preview", attachment.preview) + jsonObj.put("width", attachment.width) + jsonObj.put("height", attachment.height) + } + } else { + // Для других типов сохраняем как есть + jsonObj.put("id", attachment.id) + jsonObj.put("blob", attachment.blob) + jsonObj.put("type", attachment.type.value) + jsonObj.put("preview", attachment.preview) + jsonObj.put("width", attachment.width) + jsonObj.put("height", attachment.height) + } + + jsonArray.put(jsonObj) + } + return jsonArray.toString() + } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 46a3ac0..6fd1012 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -422,11 +422,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { */ private fun entityToChatMessage(entity: MessageEntity): ChatMessage { // Парсим attachments для поиска MESSAGES (цитата) - val replyData = parseReplyFromAttachments(entity.attachments, entity.fromMe == 1) + var replyData = parseReplyFromAttachments(entity.attachments, entity.fromMe == 1) + + // Текст сообщения и возможный fallback reply из текста + var displayText = entity.plainMessage + + // Если не нашли reply в attachments, пробуем распарсить из текста + // Формат: "🇵 Reply: "текст цитаты"\n\nоснователь текст" или подобный + if (replyData == null) { + val parseResult = parseReplyFromText(entity.plainMessage) + if (parseResult != null) { + replyData = parseResult.first + displayText = parseResult.second + } + } return ChatMessage( id = entity.messageId, - text = entity.plainMessage, // Уже расшифровано при сохранении + text = displayText, // Уже расшифровано при сохранении isOutgoing = entity.fromMe == 1, timestamp = Date(entity.timestamp), status = when (entity.delivered) { @@ -440,6 +453,30 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ) } + /** + * Парсинг reply из текста сообщения (fallback формат) + * Формат: "🇵 Reply: "текст цитаты"\n\nосновной текст" + * Возвращает Pair(ReplyData, очищенный текст) или null + */ + private fun parseReplyFromText(text: String): Pair? { + // Паттерн: начинается с эмодзи флага + "Reply:" + текст в кавычках + перенос строки + val replyPattern = Regex("^[🇵🔁↩️]\\s*Reply:\\s*\"(.+?)\"\\s*\\n+(.*)$", RegexOption.DOT_MATCHES_ALL) + val match = replyPattern.find(text) ?: return null + + val replyText = match.groupValues[1] + val mainText = match.groupValues[2].trim() + + return Pair( + ReplyData( + messageId = "", + senderName = opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }, + text = replyText, + isFromMe = false // Мы не знаем кто автор из fallback формата + ), + mainText + ) + } + /** * Парсинг MESSAGES attachment для извлечения данных цитаты * Формат: [{"message_id": "...", "publicKey": "...", "message": "..."}]