From a75235158b5c54014de916f1ac7f16e01b5b57ff Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 16 Jan 2026 01:18:57 +0500 Subject: [PATCH] feat: Enhance reply handling with detailed logging and update encryption method to SHA256 --- .../rosetta/messenger/crypto/MessageCrypto.kt | 65 +++++++++++--- .../messenger/ui/chats/ChatDetailScreen.kt | 48 +++++++++-- .../messenger/ui/chats/ChatViewModel.kt | 84 +++++++++++++++++-- 3 files changed, 171 insertions(+), 26 deletions(-) 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 31fd23e..57eaf0b 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -592,24 +592,29 @@ object MessageCrypto { } /** - * 🔥 Шифрует reply blob для attachments (как в React Native) - * Использует PBKDF2 + AES-CBC с тем же ключом что и основное сообщение + * Шифрование reply blob для передачи по сети * - * В RN: encodeWithPassword(key.toString('utf-8'), JSON.stringify(reply)) - * где key = Buffer.concat([chacha_key, nonce]) - 56 bytes + * Совместим с React Native: + * 1. Compress with pako (deflate) + * 2. Generate PBKDF2 key from ChaCha key (32 bytes) as string via Buffer.toString('utf-8') + * 3. AES-256-CBC encryption * - * ВАЖНО: JavaScript Buffer.toString('utf-8') на невалидных UTF-8 байтах - * заменяет их на U+FFFD (replacement character). Это поведение нужно - * воспроизвести в Kotlin для совместимости. + * @param replyJson - JSON string to encrypt + * @param plainKeyAndNonce - 56 bytes (32 key + 24 nonce) * * Формат выхода: "ivBase64:ciphertextBase64" (совместим с desktop) */ fun encryptReplyBlob(replyJson: String, plainKeyAndNonce: ByteArray): String { return try { + android.util.Log.d("ReplyDebug", "🔐 encryptReplyBlob called:") + android.util.Log.d("ReplyDebug", " - plainKeyAndNonce size: ${plainKeyAndNonce.size}") + android.util.Log.d("ReplyDebug", " - plainKeyAndNonce hex: ${plainKeyAndNonce.joinToString("") { "%02x".format(it) }}") - // Convert keyAndNonce to string - simulate JS Buffer.toString('utf-8') behavior + // Convert plainKeyAndNonce to string - simulate JS Buffer.toString('utf-8') behavior // which replaces invalid UTF-8 sequences with U+FFFD val password = bytesToJsUtf8String(plainKeyAndNonce) + android.util.Log.d("ReplyDebug", " - password length: ${password.length}") + android.util.Log.d("ReplyDebug", " - password bytes (first 20): ${password.toByteArray(Charsets.UTF_8).take(20).joinToString(",")}") // Compress with pako (deflate) val deflater = java.util.zip.Deflater() @@ -621,7 +626,8 @@ object MessageCrypto { val compressed = compressedBuffer.copyOf(compressedSize) // PBKDF2 key derivation (matching RN: crypto.PBKDF2(password, 'rosetta', {keySize: 256/32, iterations: 1000})) - val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") + // CRITICAL: Must use SHA256 to match React Native (not SHA1!) + val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") val spec = javax.crypto.spec.PBEKeySpec( password.toCharArray(), "rosetta".toByteArray(Charsets.UTF_8), @@ -674,31 +680,54 @@ object MessageCrypto { } /** - * 🔥 Расшифровывает reply blob из attachments (как в React Native) - * Формат входа: "ivBase64:ciphertextBase64" + * Расшифровка reply blob полученного по сети + * + * Совместим с React Native: + * 1. Parse "ivBase64:ciphertextBase64" format + * 2. Generate PBKDF2 key from ChaCha key (32 bytes) as string via Buffer.toString('utf-8') + * 3. AES-256-CBC decryption + * 4. Decompress with pako (inflate) + * + * @param encryptedBlob - "ivBase64:ciphertextBase64" format + * @param plainKeyAndNonce - 56 bytes (32 key + 24 nonce) */ fun decryptReplyBlob(encryptedBlob: String, plainKeyAndNonce: ByteArray): String { return try { + android.util.Log.d("ReplyDebug", "🔓 decryptReplyBlob called:") + android.util.Log.d("ReplyDebug", " - Input length: ${encryptedBlob.length}") + android.util.Log.d("ReplyDebug", " - plainKeyAndNonce size: ${plainKeyAndNonce.size}") + android.util.Log.d("ReplyDebug", " - plainKeyAndNonce hex: ${plainKeyAndNonce.joinToString("") { "%02x".format(it) }}") // Check if it's encrypted format (contains ':') if (!encryptedBlob.contains(':')) { + android.util.Log.d("ReplyDebug", " - No ':' found, returning as-is") return encryptedBlob } // Parse ivBase64:ciphertextBase64 val parts = encryptedBlob.split(':') if (parts.size != 2) { + android.util.Log.d("ReplyDebug", " - Invalid format (not 2 parts), returning as-is") return encryptedBlob } + android.util.Log.d("ReplyDebug", " - IV part length: ${parts[0].length}") + android.util.Log.d("ReplyDebug", " - Ciphertext part length: ${parts[1].length}") + val iv = Base64.decode(parts[0], Base64.DEFAULT) val ciphertext = Base64.decode(parts[1], Base64.DEFAULT) - // Password from keyAndNonce - use same JS-like UTF-8 conversion + android.util.Log.d("ReplyDebug", " - Decoded IV size: ${iv.size}") + android.util.Log.d("ReplyDebug", " - Decoded ciphertext size: ${ciphertext.size}") + + // Password from plainKeyAndNonce - use same JS-like UTF-8 conversion val password = bytesToJsUtf8String(plainKeyAndNonce) + android.util.Log.d("ReplyDebug", " - Password length: ${password.length}") + android.util.Log.d("ReplyDebug", " - Password bytes hex: ${password.toByteArray(Charsets.UTF_8).joinToString("") { "%02x".format(it) }}") // PBKDF2 key derivation - val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") + // CRITICAL: Must use SHA256 to match React Native (not SHA1!) + val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") val spec = javax.crypto.spec.PBEKeySpec( password.toCharArray(), "rosetta".toByteArray(Charsets.UTF_8), @@ -708,6 +737,8 @@ object MessageCrypto { val secretKey = factory.generateSecret(spec) val keyBytes = secretKey.encoded + android.util.Log.d("ReplyDebug", " - Derived key size: ${keyBytes.size}") + // AES-CBC decryption val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") val keySpec = SecretKeySpec(keyBytes, "AES") @@ -715,6 +746,8 @@ object MessageCrypto { cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec) val decompressed = cipher.doFinal(ciphertext) + android.util.Log.d("ReplyDebug", " - Decrypted (compressed) size: ${decompressed.size}") + // Decompress with inflate val inflater = java.util.zip.Inflater() inflater.setInput(decompressed) @@ -723,8 +756,14 @@ object MessageCrypto { inflater.end() val plaintext = String(outputBuffer, 0, outputSize, Charsets.UTF_8) + android.util.Log.d("ReplyDebug", " - Decompressed plaintext length: ${plaintext.length}") + android.util.Log.d("ReplyDebug", " - Plaintext preview: ${plaintext.take(100)}") + android.util.Log.d("ReplyDebug", "✅ decryptReplyBlob success") + plaintext } catch (e: Exception) { + android.util.Log.e("ReplyDebug", "❌ decryptReplyBlob failed:", e) + android.util.Log.e("ReplyDebug", " - Exception: ${e.javaClass.simpleName}: ${e.message}") // Return as-is, might be plain JSON encryptedBlob } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 3ff7648..5d54516 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -989,13 +989,35 @@ fun ChatDetailScreen( } Column(modifier = bottomModifier) { - // 🔥 UNIFIED BOTTOM BAR - один контейнер, контент меняется внутри - Crossfade( + // 🔥 UNIFIED BOTTOM BAR - один контейнер, контент меняется внутри с плавной анимацией + AnimatedContent( targetState = isSelectionMode, - animationSpec = tween(200), + transitionSpec = { + if (targetState) { + // Selection mode появляется: снизу вверх + slideInVertically( + animationSpec = tween(300, easing = TelegramEasing), + initialOffsetY = { it } + ) + fadeIn(animationSpec = tween(200)) togetherWith + slideOutVertically( + animationSpec = tween(300, easing = TelegramEasing), + targetOffsetY = { -it } + ) + fadeOut(animationSpec = tween(150)) + } else { + // Input bar возвращается: снизу вверх + slideInVertically( + animationSpec = tween(300, easing = TelegramEasing), + initialOffsetY = { it } + ) + fadeIn(animationSpec = tween(200)) togetherWith + slideOutVertically( + animationSpec = tween(300, easing = TelegramEasing), + targetOffsetY = { it } + ) + fadeOut(animationSpec = tween(150)) + } + }, label = "bottomBarContent" ) { selectionMode -> - android.util.Log.d("ChatDetailScreen", "🎬 Crossfade to selectionMode=$selectionMode") + android.util.Log.d("ChatDetailScreen", "🎬 AnimatedContent to selectionMode=$selectionMode") if (selectionMode) { // SELECTION ACTION BAR - Reply/Forward @@ -1013,13 +1035,25 @@ fun ChatDetailScreen( .background(if (isDarkTheme) Color.White.copy(alpha = 0.15f) else Color.Black.copy(alpha = 0.1f)) ) - // Кнопки Reply и Forward - такие же отступы как у input row + // Кнопки Reply и Forward - плавная анимация появления + val buttonScale by animateFloatAsState( + targetValue = if (selectionMode) 1f else 0.95f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "buttonScale" + ) + Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp) - .padding(bottom = 16.dp) - .navigationBarsPadding(), + .navigationBarsPadding() + .graphicsLayer { + scaleX = buttonScale + scaleY = buttonScale + }, horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { 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 3fbcb3e..69fc2a1 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 @@ -228,22 +228,36 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { var blobToStore = att.blob // По умолчанию сохраняем оригинальный blob if (att.type == AttachmentType.MESSAGES && att.blob.isNotEmpty()) { try { - // 🔥 Сначала расшифровываем blob (он зашифрован!) + android.util.Log.d("ReplyDebug", "📥 [RECEIVE] Processing reply attachment:") + android.util.Log.d("ReplyDebug", " - Encrypted blob length: ${att.blob.length}") + android.util.Log.d("ReplyDebug", " - Encrypted blob preview: ${att.blob.take(100)}") + android.util.Log.d("ReplyDebug", " - Decrypting with plainKeyAndNonce (${plainKeyAndNonce.size} bytes)") + android.util.Log.d("ReplyDebug", " - plainKeyAndNonce hex: ${plainKeyAndNonce.joinToString("") { "%02x".format(it) }}") + + // 🔥 Расшифровываем с полным plainKeyAndNonce (56 bytes) + // Desktop использует chachaDecryptedKey.toString('utf-8') = полные 56 байт! val decryptedBlob = MessageCrypto.decryptReplyBlob(att.blob, plainKeyAndNonce) + android.util.Log.d("ReplyDebug", " - Decrypted blob length: ${decryptedBlob.length}") + android.util.Log.d("ReplyDebug", " - Decrypted blob preview: ${decryptedBlob.take(200)}") // 🔥 Сохраняем расшифрованный blob в БД blobToStore = decryptedBlob // Парсим JSON массив с цитируемыми сообщениями val replyArray = JSONArray(decryptedBlob) + android.util.Log.d("ReplyDebug", " - Reply array length: ${replyArray.length()}") if (replyArray.length() > 0) { val firstReply = replyArray.getJSONObject(0) val replyPublicKey = firstReply.optString("publicKey", "") val replyText = firstReply.optString("message", "") val replyMessageId = firstReply.optString("message_id", "") + android.util.Log.d("ReplyDebug", " - Parsed reply: id=$replyMessageId") + android.util.Log.d("ReplyDebug", " publicKey=${replyPublicKey.take(20)}...") + android.util.Log.d("ReplyDebug", " message=${replyText.take(50)}") // Определяем автора цитаты val isReplyFromMe = replyPublicKey == myPublicKey + android.util.Log.d("ReplyDebug", " - Is reply from me: $isReplyFromMe") replyData = ReplyData( messageId = replyMessageId, @@ -251,8 +265,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { text = replyText, isFromMe = isReplyFromMe ) + android.util.Log.d("ReplyDebug", "✅ [RECEIVE] Reply data created successfully") } } catch (e: Exception) { + android.util.Log.e("ReplyDebug", "❌ [RECEIVE] Failed to decrypt/parse reply:", e) + android.util.Log.e("ReplyDebug", " - Encrypted blob: ${att.blob.take(100)}") } } @@ -608,7 +625,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } // Парсим attachments для поиска MESSAGES (цитата) - var replyData = parseReplyFromAttachments(entity.attachments, entity.fromMe == 1) + // 🔥 ВАЖНО: Передаем content и chachaKey для расшифровки reply blob если нужно + var replyData = parseReplyFromAttachments( + attachmentsJson = entity.attachments, + isFromMe = entity.fromMe == 1, + content = entity.content, + chachaKey = entity.chachaKey + ) // Если не нашли reply в attachments, пробуем распарсить из текста if (replyData == null) { @@ -662,24 +685,45 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { /** * Парсинг MESSAGES attachment для извлечения данных цитаты * Формат: [{"message_id": "...", "publicKey": "...", "message": "..."}] + * 🔥 ВАЖНО: Если blob зашифрован (формат "iv:ciphertext"), расшифровываем его */ - private fun parseReplyFromAttachments(attachmentsJson: String, isFromMe: Boolean): ReplyData? { + private suspend fun parseReplyFromAttachments( + attachmentsJson: String, + isFromMe: Boolean, + content: String, + chachaKey: String + ): ReplyData? { if (attachmentsJson.isEmpty() || attachmentsJson == "[]") return null return try { + android.util.Log.d("ReplyDebug", "💾 [DB] Parsing reply from attachments JSON") + android.util.Log.d("ReplyDebug", " - Attachments JSON preview: ${attachmentsJson.take(200)}") val attachments = JSONArray(attachmentsJson) + android.util.Log.d("ReplyDebug", " - Attachments count: ${attachments.length()}") for (i in 0 until attachments.length()) { val attachment = attachments.getJSONObject(i) val type = attachment.optInt("type", 0) + android.util.Log.d("ReplyDebug", " - Attachment $i type: $type") // MESSAGES = 1 (цитата) if (type == 1) { // Данные могут быть в blob или preview - val dataJson = attachment.optString("blob", "").ifEmpty { + var dataJson = attachment.optString("blob", "").ifEmpty { attachment.optString("preview", "") } + android.util.Log.d("ReplyDebug", " - Found MESSAGES attachment, data length: ${dataJson.length}") + android.util.Log.d("ReplyDebug", " - Data preview: ${dataJson.take(200)}") if (dataJson.isEmpty()) continue + // 🔥 Проверяем формат blob - если содержит ":", то это зашифрованный формат "iv:ciphertext" + // Это старые сообщения (полученные до фикса) которые нельзя расшифровать + if (dataJson.contains(":") && dataJson.split(":").size == 2) { + android.util.Log.d("ReplyDebug", " - Blob is encrypted (old format), skipping...") + android.util.Log.d("ReplyDebug", " - Cannot decrypt old reply messages - they were saved before fix") + // Пропускаем старые зашифрованные сообщения + continue + } + val messagesArray = JSONArray(dataJson) if (messagesArray.length() > 0) { val replyMessage = messagesArray.getJSONObject(0) @@ -691,17 +735,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Если publicKey == myPublicKey - цитата от меня val isReplyFromMe = replyPublicKey == myPublicKey - return ReplyData( + android.util.Log.d("ReplyDebug", " - Parsed from DB: id=$replyMessageId, text=${replyText.take(30)}, isFromMe=$isReplyFromMe") + val result = ReplyData( messageId = replyMessageId, senderName = if (isReplyFromMe) "You" else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }, text = replyText, isFromMe = isReplyFromMe ) + android.util.Log.d("ReplyDebug", "✅ [DB] Reply data parsed successfully from DB") + return result } } } + android.util.Log.d("ReplyDebug", "💾 [DB] No MESSAGES attachment found") null } catch (e: Exception) { + android.util.Log.e("ReplyDebug", "❌ [DB] Failed to parse reply from attachments:", e) null } } @@ -893,10 +942,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { var replyBlobPlaintext = "" // Сохраняем plaintext для БД if (replyMsgsToSend.isNotEmpty()) { + android.util.Log.d("ReplyDebug", "📤 [SEND] Creating reply attachment:") + android.util.Log.d("ReplyDebug", " - Reply messages count: ${replyMsgsToSend.size}") // Формируем JSON массив с цитируемыми сообщениями (как в RN) val replyJsonArray = JSONArray() replyMsgsToSend.forEach { msg -> + android.util.Log.d("ReplyDebug", " - Adding reply: id=${msg.messageId}, publicKey=${msg.publicKey.take(20)}..., text=${msg.text.take(30)}") val replyJson = JSONObject().apply { put("message_id", msg.messageId) put("publicKey", msg.publicKey) @@ -908,9 +960,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } replyBlobPlaintext = replyJsonArray.toString() // 🔥 Сохраняем plaintext + android.util.Log.d("ReplyDebug", " - Reply blob plaintext length: ${replyBlobPlaintext.length}") + android.util.Log.d("ReplyDebug", " - Reply blob preview: ${replyBlobPlaintext.take(100)}") + android.util.Log.d("ReplyDebug", " - Encrypting reply blob with plainKeyAndNonce (${plainKeyAndNonce.size} bytes)") - // 🔥 Шифруем reply blob plainKeyAndNonce (как в React Native) + // 🔥 Шифруем reply blob (для network transmission) val encryptedReplyBlob = MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce) + android.util.Log.d("ReplyDebug", " - Encrypted blob length: ${encryptedReplyBlob.length}") + android.util.Log.d("ReplyDebug", " - Encrypted blob preview: ${encryptedReplyBlob.take(100)}") val replyAttachmentId = "reply_${timestamp}" messageAttachments.add(MessageAttachment( @@ -919,6 +976,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { type = AttachmentType.MESSAGES, preview = "" )) + android.util.Log.d("ReplyDebug", "✅ [SEND] Reply attachment added to packet (encrypted)") } val packet = PacketMessage().apply { @@ -932,6 +990,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { attachments = messageAttachments } + // 🔥 DEBUG: Log packet before sending + android.util.Log.d("ReplyDebug", "📤 [SEND] About to send packet:") + android.util.Log.d("ReplyDebug", " - messageId: $messageId") + android.util.Log.d("ReplyDebug", " - from: ${sender.take(20)}...") + android.util.Log.d("ReplyDebug", " - to: ${recipient.take(20)}...") + android.util.Log.d("ReplyDebug", " - attachments count: ${packet.attachments.size}") + packet.attachments.forEachIndexed { idx, att -> + android.util.Log.d("ReplyDebug", " - Attachment $idx:") + android.util.Log.d("ReplyDebug", " type: ${att.type.value}") + android.util.Log.d("ReplyDebug", " id: ${att.id}") + android.util.Log.d("ReplyDebug", " blob length: ${att.blob.length}") + android.util.Log.d("ReplyDebug", " blob preview: ${att.blob.take(100)}") + } + // Отправляем пакет ProtocolManager.send(packet)