diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 1037fc4..983bfcd 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -41,6 +41,7 @@ import com.rosetta.messenger.ui.auth.AuthFlow import com.rosetta.messenger.ui.auth.DeviceConfirmScreen import com.rosetta.messenger.ui.chats.ChatDetailScreen import com.rosetta.messenger.ui.chats.ChatsListScreen +import com.rosetta.messenger.ui.chats.ConnectionLogsScreen import com.rosetta.messenger.ui.chats.RequestsListScreen import com.rosetta.messenger.ui.chats.SearchScreen import com.rosetta.messenger.ui.components.OptimizedEmojiCache @@ -506,6 +507,7 @@ sealed class Screen { data object CrashLogs : Screen() data object Biometric : Screen() data object Appearance : Screen() + data object DebugLogs : Screen() } @Composable @@ -614,6 +616,9 @@ fun MainScreen( val isAppearanceVisible by remember { derivedStateOf { navStack.any { it is Screen.Appearance } } } + val isDebugLogsVisible by remember { + derivedStateOf { navStack.any { it is Screen.DebugLogs } } + } // Navigation helpers fun pushScreen(screen: Screen) { @@ -635,7 +640,8 @@ fun MainScreen( it is Screen.Logs || it is Screen.CrashLogs || it is Screen.Biometric || - it is Screen.Appearance + it is Screen.Appearance || + it is Screen.DebugLogs } } fun popChatAndChildren() { @@ -712,6 +718,7 @@ fun MainScreen( ) }, onSettingsClick = { pushScreen(Screen.Profile) }, + onLogsClick = { pushScreen(Screen.DebugLogs) }, onInviteFriendsClick = { // TODO: Share invite link }, @@ -1003,6 +1010,17 @@ fun MainScreen( ) } + SwipeBackContainer( + isVisible = isDebugLogsVisible, + onBack = { navStack = navStack.filterNot { it is Screen.DebugLogs } }, + isDarkTheme = isDarkTheme + ) { + ConnectionLogsScreen( + isDarkTheme = isDarkTheme, + onBack = { navStack = navStack.filterNot { it is Screen.DebugLogs } } + ) + } + var isOtherProfileSwipeEnabled by remember { mutableStateOf(true) } LaunchedEffect(selectedOtherUser?.publicKey) { isOtherProfileSwipeEnabled = true 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 86e4ac8..6ec21ec 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -52,6 +52,16 @@ object MessageCrypto { val plaintext: String, // Расшифрованный текст val plainKeyAndNonce: ByteArray // Raw key+nonce для расшифровки attachments ) + + data class AttachmentDecryptDebugResult( + val decrypted: String?, + val trace: List + ) + + private data class AttachmentDecryptAttemptResult( + val decrypted: String?, + val reason: String + ) /** * XChaCha20-Poly1305 шифрование (совместимо с @noble/ciphers в RN) @@ -388,6 +398,15 @@ object MessageCrypto { * КРИТИЧНО: ephemeralPrivateKeyHex может иметь нечётную длину! */ fun decryptKeyFromSender(encryptedKeyBase64: String, myPrivateKeyHex: String): ByteArray { + val candidates = decryptKeyFromSenderCandidates(encryptedKeyBase64, myPrivateKeyHex) + return candidates.firstOrNull() + ?: throw IllegalArgumentException("Failed to decrypt key: no valid candidates") + } + + fun decryptKeyFromSenderCandidates( + encryptedKeyBase64: String, + myPrivateKeyHex: String + ): List { if (encryptedKeyBase64.startsWith(SYNC_KEY_PREFIX)) { val aesChachaKey = encryptedKeyBase64.removePrefix(SYNC_KEY_PREFIX) if (aesChachaKey.isBlank()) { @@ -396,7 +415,7 @@ object MessageCrypto { val decoded = CryptoManager.decryptWithPassword(aesChachaKey, myPrivateKeyHex) ?: throw IllegalArgumentException("Failed to decrypt sync chacha key") - return decoded.toByteArray(Charsets.ISO_8859_1) + return listOf(decoded.toByteArray(Charsets.ISO_8859_1)) } val combined = String(Base64.decode(encryptedKeyBase64, Base64.NO_WRAP)) @@ -434,33 +453,66 @@ object MessageCrypto { // ECDH: ephemeralPrivate * myPublic = sharedSecret val sharedPoint = myPublicKey.multiply(ephemeralPrivateKey) - - // ⚠️ КРИТИЧНО: Эмулируем JS поведение! - // JS: BN.toString(16) НЕ добавляет ведущие нули + + // Desktop parity: noble/secp256k1 getSharedSecret(...).slice(1, 33) + // => ровно 32 байта X-координаты С сохранением ведущих нулей. + // Для обратной совместимости пробуем и старый Android-вариант (trim leading zeros). val xCoordBigInt = sharedPoint.normalize().xCoord.toBigInteger() - var sharedSecretHex = xCoordBigInt.toString(16) - - if (sharedSecretHex.length % 2 != 0) { - sharedSecretHex = "0$sharedSecretHex" + val sharedSecretExact = bigIntegerToFixed32Bytes(xCoordBigInt) + + // Legacy (старый Android): BN.toString(16) + hex parse (теряет ведущие нули) + var sharedSecretHexLegacy = xCoordBigInt.toString(16) + if (sharedSecretHexLegacy.length % 2 != 0) { + sharedSecretHexLegacy = "0$sharedSecretHexLegacy" + } + val sharedSecretLegacy = sharedSecretHexLegacy.hexToBytes() + + val candidateMap = LinkedHashMap() + val decryptedVariants = listOf( + decryptKeyAesPayload(encryptedKey, iv, sharedSecretExact), + decryptKeyAesPayload(encryptedKey, iv, sharedSecretLegacy) + ) + + for (decryptedUtf8Bytes in decryptedVariants) { + if (decryptedUtf8Bytes == null) continue + // ⚠️ КРИТИЧНО: Обратная конвертация UTF-8 → Latin1! + // Desktop: decrypted.toString(crypto.enc.Utf8) → Buffer.from(str, 'binary') + // Это декодирует UTF-8 в строку, потом берёт charCode каждого символа + val utf8String = String(decryptedUtf8Bytes, Charsets.UTF_8) + val originalBytes = utf8String.toByteArray(Charsets.ISO_8859_1) + + // В протоколе это key(32)+nonce(24). Фильтруем мусор после ложнопозитивной PKCS5 padding. + if (originalBytes.size < 56) continue + + val fp = shortSha256(originalBytes) + candidateMap.putIfAbsent(fp, originalBytes) + } + + return candidateMap.values.toList() + } + + private fun decryptKeyAesPayload( + encryptedKey: ByteArray, + iv: ByteArray, + sharedSecret: ByteArray + ): ByteArray? { + return try { + val aesKey = SecretKeySpec(sharedSecret, "AES") + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv)) + cipher.doFinal(encryptedKey) + } catch (_: Exception) { + null + } + } + + private fun bigIntegerToFixed32Bytes(value: BigInteger): ByteArray { + val raw = value.toByteArray() + return when { + raw.size == 32 -> raw + raw.size > 32 -> raw.copyOfRange(raw.size - 32, raw.size) + else -> ByteArray(32 - raw.size) + raw } - val sharedSecret = sharedSecretHex.hexToBytes() - - // Расшифровываем используя sharedSecret как AES ключ - - val aesKey = SecretKeySpec(sharedSecret, "AES") - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv)) - - val decryptedUtf8Bytes = cipher.doFinal(encryptedKey) - - // ⚠️ КРИТИЧНО: Обратная конвертация UTF-8 → Latin1! - // Desktop: decrypted.toString(crypto.enc.Utf8) → Buffer.from(str, 'binary') - // Это декодирует UTF-8 в строку, потом берёт charCode каждого символа - val utf8String = String(decryptedUtf8Bytes, Charsets.UTF_8) - - val originalBytes = utf8String.toByteArray(Charsets.ISO_8859_1) - - return originalBytes } /** @@ -499,19 +551,27 @@ object MessageCrypto { encryptedKey: String, myPrivateKey: String ): DecryptedIncoming { - - // 1. Расшифровываем ключ - val keyAndNonce = decryptKeyFromSender(encryptedKey, myPrivateKey) - - // 2. Разделяем key и nonce - val key = keyAndNonce.slice(0 until 32).toByteArray() - val nonce = keyAndNonce.slice(32 until keyAndNonce.size).toByteArray() - - // 3. Расшифровываем сообщение - val plaintext = decryptMessage(ciphertext, key.toHex(), nonce.toHex()) - - - return DecryptedIncoming(plaintext, keyAndNonce) + val keyCandidates = decryptKeyFromSenderCandidates(encryptedKey, myPrivateKey) + if (keyCandidates.isEmpty()) { + throw IllegalArgumentException("Failed to decrypt message key: no candidates") + } + + var lastError: Exception? = null + for (keyAndNonce in keyCandidates) { + if (keyAndNonce.size < 56) continue + + val key = keyAndNonce.copyOfRange(0, 32) + val nonce = keyAndNonce.copyOfRange(32, 56) + + try { + val plaintext = decryptMessage(ciphertext, key.toHex(), nonce.toHex()) + return DecryptedIncoming(plaintext, keyAndNonce) + } catch (e: Exception) { + lastError = e + } + } + + throw (lastError ?: IllegalArgumentException("Failed to decrypt message content with all key candidates")) } /** @@ -566,16 +626,55 @@ object MessageCrypto { encryptedData: String, chachaKeyPlain: ByteArray ): String? { - // Один путь, как в десктопе: bytesToJsUtf8String → PBKDF2-SHA256 → AES-CBC → inflate + return decryptAttachmentBlobWithPlainKeyDebug(encryptedData, chachaKeyPlain).decrypted + } + + fun decryptAttachmentBlobWithPlainKeyDebug( + encryptedData: String, + chachaKeyPlain: ByteArray + ): AttachmentDecryptDebugResult { + val trace = mutableListOf() return try { - val password = bytesToJsUtf8String(chachaKeyPlain) - val pbkdf2Key = generatePBKDF2Key(password) - decryptWithPBKDF2Key(encryptedData, pbkdf2Key) - } catch (_: Exception) { - null + trace += "payload=${describeAttachmentPayload(encryptedData)}" + trace += "key=size${chachaKeyPlain.size},fp=${shortSha256(chachaKeyPlain)}" + + val candidates = buildAttachmentPasswordCandidates(chachaKeyPlain) + trace += "candidates=${candidates.size}" + + for ((index, password) in candidates.withIndex()) { + val passwordBytesUtf8 = password.toByteArray(Charsets.UTF_8) + trace += + "cand[$index]=chars${password.length},utf8${passwordBytesUtf8.size},fp=${shortSha256(passwordBytesUtf8)}" + + val pbkdf2KeySha256 = generatePBKDF2Key(password) + trace += "cand[$index]=pbkdf2-sha256:${shortSha256(pbkdf2KeySha256)}" + + val sha256Attempt = decryptWithPBKDF2KeyDebug(encryptedData, pbkdf2KeySha256) + if (sha256Attempt.decrypted != null) { + trace += "cand[$index]=SUCCESS:sha256,len${sha256Attempt.decrypted.length}" + return AttachmentDecryptDebugResult(sha256Attempt.decrypted, trace) + } + trace += "cand[$index]=fail:sha256:${sha256Attempt.reason}" + + val pbkdf2KeySha1 = generatePBKDF2KeySha1FromBytes(passwordBytesUtf8) + trace += "cand[$index]=pbkdf2-sha1:${shortSha256(pbkdf2KeySha1)}" + val sha1Attempt = decryptWithPBKDF2KeyDebug(encryptedData, pbkdf2KeySha1) + if (sha1Attempt.decrypted != null) { + trace += "cand[$index]=SUCCESS:sha1,len${sha1Attempt.decrypted.length}" + return AttachmentDecryptDebugResult(sha1Attempt.decrypted, trace) + } + trace += "cand[$index]=fail:sha1:${sha1Attempt.reason}" + } + + trace += "result=failed_all_candidates" + AttachmentDecryptDebugResult(null, trace) + } catch (e: Exception) { + trace += "exception=${e.javaClass.simpleName}:${e.message?.take(120)}" + AttachmentDecryptDebugResult(null, trace) } catch (_: OutOfMemoryError) { System.gc() - null + trace += "exception=OutOfMemoryError" + AttachmentDecryptDebugResult(null, trace) } } @@ -600,8 +699,8 @@ object MessageCrypto { outputFile: java.io.File ): Boolean { return try { - val password = bytesToJsUtf8String(chachaKeyPlain) - val pbkdf2Key = generatePBKDF2Key(password) + val passwordCandidates = buildAttachmentPasswordCandidates(chachaKeyPlain) + if (passwordCandidates.isEmpty()) return false // Проверяем формат: CHNK: или обычный ivBase64:ciphertextBase64 val header = ByteArray(5) @@ -615,11 +714,26 @@ object MessageCrypto { } val isChunked = headerLen == 5 && String(header, Charsets.US_ASCII) == "CHNK:" - if (isChunked) { - decryptChunkedFileStreaming(inputFile, pbkdf2Key, outputFile) - } else { - decryptSingleFileStreaming(inputFile, pbkdf2Key, outputFile) + for (password in passwordCandidates) { + val passwordBytesUtf8 = password.toByteArray(Charsets.UTF_8) + val pbkdf2Sha256 = generatePBKDF2KeyFromBytes(passwordBytesUtf8) + val pbkdf2Sha1 = generatePBKDF2KeySha1FromBytes(passwordBytesUtf8) + val pbkdf2Candidates = linkedSetOf(pbkdf2Sha256, pbkdf2Sha1) + + for (pbkdf2Key in pbkdf2Candidates) { + outputFile.delete() + val ok = + if (isChunked) { + decryptChunkedFileStreaming(inputFile, pbkdf2Key, outputFile) + } else { + decryptSingleFileStreaming(inputFile, pbkdf2Key, outputFile) + } + if (ok) { + return true + } + } } + false } catch (e: Exception) { android.util.Log.e("MessageCrypto", "Streaming decrypt failed", e) outputFile.delete() @@ -819,19 +933,25 @@ object MessageCrypto { passwordLatin1: String ): String? { return try { - + // Конвертируем Latin1 string → bytes → UTF-8 string (эмулируем Desktop) // Desktop: Buffer.from(str, 'binary').toString('utf-8') val passwordBytes = passwordLatin1.toByteArray(Charsets.ISO_8859_1) - val passwordUtf8 = bytesToJsUtf8String(passwordBytes) - - // Генерируем PBKDF2 ключ (salt='rosetta', 1000 iterations, sha1) - // Crypto-js конвертирует passwordUtf8 string в UTF-8 bytes для PBKDF2 - val pbkdf2Key = generatePBKDF2Key(passwordUtf8) - - // Расшифровываем AES-256-CBC + zlib decompress - val result = decryptWithPBKDF2Key(encryptedData, pbkdf2Key) - result + val passwordCandidates = linkedSetOf( + bytesToBufferPolyfillUtf8String(passwordBytes), + bytesToJsUtf8String(passwordBytes) + ) + + for (passwordUtf8 in passwordCandidates) { + // Генерируем PBKDF2 ключ (salt='rosetta', 1000 iterations) + val pbkdf2Key = generatePBKDF2Key(passwordUtf8) + decryptWithPBKDF2Key(encryptedData, pbkdf2Key)?.let { return it } + + // Legacy fallback (sha1) + val pbkdf2KeySha1 = generatePBKDF2KeySha1FromBytes(passwordUtf8.toByteArray(Charsets.UTF_8)) + decryptWithPBKDF2Key(encryptedData, pbkdf2KeySha1)?.let { return it } + } + null } catch (e: Exception) { null } catch (_: OutOfMemoryError) { @@ -851,12 +971,11 @@ object MessageCrypto { myPrivateKey: String ): String? { return try { - - // 1. Расшифровываем ChaCha ключ+nonce (56 bytes) через ECDH - val keyAndNonce = decryptKeyFromSender(encryptedKey, myPrivateKey) - - // 2. Используем ВСЕ 56 байт как password для PBKDF2 - decryptAttachmentBlobWithPlainKey(encryptedData, keyAndNonce) + val keyCandidates = decryptKeyFromSenderCandidates(encryptedKey, myPrivateKey) + for (keyAndNonce in keyCandidates) { + decryptAttachmentBlobWithPlainKey(encryptedData, keyAndNonce)?.let { return it } + } + null } catch (e: Exception) { null } catch (_: OutOfMemoryError) { @@ -891,9 +1010,40 @@ object MessageCrypto { private fun generatePBKDF2KeyFromBytes(passwordBytes: ByteArray, saltBytes: ByteArray = "rosetta".toByteArray(Charsets.UTF_8), iterations: Int = 1000): ByteArray { // PBKDF2-HMAC-SHA256 ручная реализация для совместимости с crypto-js // ВАЖНО: crypto-js PBKDF2 по умолчанию использует SHA256, НЕ SHA1! + return generatePBKDF2KeyFromBytesWithHmac( + passwordBytes = passwordBytes, + saltBytes = saltBytes, + iterations = iterations, + hmacAlgo = "HmacSHA256" + ) + } + + /** + * Генерация PBKDF2-SHA1 ключа из raw bytes. + * Нужна как fallback для legacy сообщений. + */ + private fun generatePBKDF2KeySha1FromBytes( + passwordBytes: ByteArray, + saltBytes: ByteArray = "rosetta".toByteArray(Charsets.UTF_8), + iterations: Int = 1000 + ): ByteArray { + return generatePBKDF2KeyFromBytesWithHmac( + passwordBytes = passwordBytes, + saltBytes = saltBytes, + iterations = iterations, + hmacAlgo = "HmacSHA1" + ) + } + + private fun generatePBKDF2KeyFromBytesWithHmac( + passwordBytes: ByteArray, + saltBytes: ByteArray, + iterations: Int, + hmacAlgo: String + ): ByteArray { val keyLength = 32 // 256 bits - val mac = javax.crypto.Mac.getInstance("HmacSHA256") - val keySpec = javax.crypto.spec.SecretKeySpec(passwordBytes, "HmacSHA256") + val mac = javax.crypto.Mac.getInstance(hmacAlgo) + val keySpec = javax.crypto.spec.SecretKeySpec(passwordBytes, hmacAlgo) mac.init(keySpec) // PBKDF2 алгоритм @@ -964,43 +1114,238 @@ object MessageCrypto { * Формат: ivBase64:ciphertextBase64 */ private fun decryptWithPBKDF2Key(encryptedData: String, pbkdf2Key: ByteArray): String? { + return decryptWithPBKDF2KeyDebug(encryptedData, pbkdf2Key).decrypted + } + + private fun decryptWithPBKDF2KeyDebug( + encryptedData: String, + pbkdf2Key: ByteArray + ): AttachmentDecryptAttemptResult { return try { - - val parts = encryptedData.split(":") - if (parts.size != 2) { - return null + if (encryptedData.startsWith("CHNK:")) { + val chunked = decryptChunkedWithPBKDF2KeyDebug(encryptedData, pbkdf2Key) + return AttachmentDecryptAttemptResult(chunked, if (chunked != null) "ok:chunked" else "chunked:failed") } - - val iv = android.util.Base64.decode(parts[0], android.util.Base64.DEFAULT) - val ciphertext = android.util.Base64.decode(parts[1], android.util.Base64.DEFAULT) - + + val parts = encryptedData.split(":", limit = 2) + if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) { + // Legacy desktop format: base64(ivHex:cipherHex) + val old = decryptOldFormatWithPBKDF2KeyDebug(encryptedData, pbkdf2Key) + return AttachmentDecryptAttemptResult(old.decrypted, "legacy:${old.reason}") + } + + val iv = decodeBase64Compat(parts[0]) + val ciphertext = decodeBase64Compat(parts[1]) + if (iv == null || ciphertext == null) { + val old = decryptOldFormatWithPBKDF2KeyDebug(encryptedData, pbkdf2Key) + return AttachmentDecryptAttemptResult(old.decrypted, "new_base64_invalid->legacy:${old.reason}") + } + // 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() - - val result = String(outputStream.toByteArray(), Charsets.UTF_8) - result + + val inflated = inflateToUtf8(decrypted) + AttachmentDecryptAttemptResult(inflated, "ok:new") + } catch (e: javax.crypto.BadPaddingException) { + AttachmentDecryptAttemptResult(null, "aes_bad_padding") + } catch (e: java.util.zip.DataFormatException) { + AttachmentDecryptAttemptResult(null, "inflate_data_format") } catch (e: Exception) { + AttachmentDecryptAttemptResult(null, "${e.javaClass.simpleName}:${e.message?.take(80)}") + } catch (_: OutOfMemoryError) { + System.gc() + AttachmentDecryptAttemptResult(null, "OutOfMemoryError") + } + } + + private fun decryptChunkedWithPBKDF2Key(encryptedData: String, pbkdf2Key: ByteArray): String? { + return decryptChunkedWithPBKDF2KeyDebug(encryptedData, pbkdf2Key) + } + + private fun decryptChunkedWithPBKDF2KeyDebug(encryptedData: String, pbkdf2Key: ByteArray): String? { + return try { + val raw = encryptedData.removePrefix("CHNK:") + if (raw.isBlank()) return null + + val encryptedChunks = raw.split("::").filter { it.isNotBlank() } + if (encryptedChunks.isEmpty()) return null + + val cipher = javax.crypto.Cipher.getInstance("AES/CBC/PKCS5Padding") + val secretKey = javax.crypto.spec.SecretKeySpec(pbkdf2Key, "AES") + val compressedOutput = java.io.ByteArrayOutputStream() + + for (chunk in encryptedChunks) { + val parts = chunk.split(":", limit = 2) + if (parts.size != 2) return null + + val iv = decodeBase64Compat(parts[0]) ?: return null + val ciphertext = decodeBase64Compat(parts[1]) ?: return null + + val ivSpec = javax.crypto.spec.IvParameterSpec(iv) + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, secretKey, ivSpec) + val decryptedChunk = cipher.doFinal(ciphertext) + compressedOutput.write(decryptedChunk) + } + + inflateToUtf8(compressedOutput.toByteArray()) + } catch (e: Exception) { + android.util.Log.w( + "MessageCrypto", + "decryptChunkedWithPBKDF2Key: ${e.javaClass.simpleName}: ${e.message}" + ) null } catch (_: OutOfMemoryError) { System.gc() null } } + + private fun decryptWithPBKDF2KeySha1(encryptedData: String, passwordBytes: ByteArray): String? { + return try { + val keyBytesSha1 = generatePBKDF2KeySha1FromBytes(passwordBytes) + decryptWithPBKDF2Key(encryptedData, keyBytesSha1) + } catch (_: Exception) { + null + } catch (_: OutOfMemoryError) { + System.gc() + null + } + } + + /** + * Desktop legacy decodeWithPassword support: + * data = base64("ivHex:cipherHex") + */ + private fun decryptOldFormatWithPBKDF2Key( + encryptedData: String, + pbkdf2Key: ByteArray + ): String? { + return decryptOldFormatWithPBKDF2KeyDebug(encryptedData, pbkdf2Key).decrypted + } + + private fun decryptOldFormatWithPBKDF2KeyDebug( + encryptedData: String, + pbkdf2Key: ByteArray + ): AttachmentDecryptAttemptResult { + return try { + val decoded = decodeBase64Compat(encryptedData) + ?: return AttachmentDecryptAttemptResult(null, "base64_decode_failed") + val decodedText = String(decoded, Charsets.UTF_8) + val parts = decodedText.split(":", limit = 2) + if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) { + return AttachmentDecryptAttemptResult(null, "decoded_not_ivhex_cthex") + } + + val iv = parts[0].hexToBytes() + val ciphertext = parts[1].hexToBytes() + + 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 decryptedUtf8 = cipher.doFinal(ciphertext) + + val text = String(decryptedUtf8, Charsets.UTF_8) + if (text.isEmpty()) { + AttachmentDecryptAttemptResult(null, "decrypted_empty") + } else { + AttachmentDecryptAttemptResult(text, "ok") + } + } catch (e: javax.crypto.BadPaddingException) { + AttachmentDecryptAttemptResult(null, "aes_bad_padding") + } catch (e: Exception) { + AttachmentDecryptAttemptResult(null, "${e.javaClass.simpleName}:${e.message?.take(80)}") + } + } + + private fun decodeBase64Compat(raw: String): ByteArray? { + val cleaned = raw.trim().replace("\n", "").replace("\r", "") + if (cleaned.isEmpty()) return null + return try { + Base64.decode(cleaned, Base64.DEFAULT) + } catch (_: IllegalArgumentException) { + try { + val pad = (4 - (cleaned.length % 4)) % 4 + Base64.decode(cleaned + "=".repeat(pad), Base64.DEFAULT) + } catch (_: IllegalArgumentException) { + null + } + } + } + + private fun shortSha256(bytes: ByteArray): String { + return try { + val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + digest.copyOfRange(0, 6).joinToString("") { "%02x".format(it) } + } catch (_: Exception) { + "shaerr" + } + } + + private fun describeAttachmentPayload(encryptedData: String): String { + return try { + val colonCount = encryptedData.count { it == ':' } + + if (encryptedData.startsWith("CHNK:")) { + val raw = encryptedData.removePrefix("CHNK:") + val chunks = raw.split("::").filter { it.isNotBlank() } + val firstChunk = chunks.firstOrNull().orEmpty() + val firstParts = firstChunk.split(":", limit = 2) + val firstIvB64Len = firstParts.getOrNull(0)?.length ?: 0 + val firstCtB64Len = firstParts.getOrNull(1)?.length ?: 0 + val firstIvBytes = firstParts.getOrNull(0)?.let { decodeBase64Compat(it)?.size } ?: -1 + return "chunked,len=${encryptedData.length},colons=$colonCount,chunks=${chunks.size},firstIvB64=$firstIvB64Len,firstCtB64=$firstCtB64Len,firstIvBytes=$firstIvBytes" + } + + val parts = encryptedData.split(":", limit = 2) + if (parts.size == 2) { + val ivBytes = decodeBase64Compat(parts[0])?.size ?: -1 + val ctBytes = decodeBase64Compat(parts[1])?.size ?: -1 + return "new-or-legacy2,len=${encryptedData.length},colons=$colonCount,ivB64=${parts[0].length},ctB64=${parts[1].length},ivBytes=$ivBytes,ctBytes=$ctBytes" + } + + val decoded = decodeBase64Compat(encryptedData) + if (decoded != null) { + val decodedText = String(decoded, Charsets.UTF_8) + val legacyParts = decodedText.split(":", limit = 2) + if (legacyParts.size == 2) { + return "legacy-base64,len=${encryptedData.length},decoded=${decoded.size},ivHex=${legacyParts[0].length},ctHex=${legacyParts[1].length}" + } + return "base64-unknown,len=${encryptedData.length},decoded=${decoded.size},colons=$colonCount" + } + + "unknown,len=${encryptedData.length},colons=$colonCount" + } catch (_: Exception) { + "inspect-error,len=${encryptedData.length}" + } + } + + /** + * Собираем пароль-кандидаты для полной desktop совместимости: + * - full key+nonce (56 bytes) и legacy key-only (32 bytes) + * - Buffer polyfill UTF-8 decode (desktop runtime parity: window.Buffer from "buffer") + * - WHATWG/Node UTF-8 decode + * - JVM UTF-8 / Latin1 fallback + */ + private fun buildAttachmentPasswordCandidates(chachaKeyPlain: ByteArray): List { + val candidates = LinkedHashSet(12) + + fun addVariants(bytes: ByteArray) { + if (bytes.isEmpty()) return + candidates.add(bytesToBufferPolyfillUtf8String(bytes)) + candidates.add(bytesToJsUtf8String(bytes)) + candidates.add(String(bytes, Charsets.UTF_8)) + candidates.add(String(bytes, Charsets.ISO_8859_1)) + } + + addVariants(chachaKeyPlain) + addVariants(chachaKeyPlain.copyOfRange(0, minOf(chachaKeyPlain.size, 32))) + return candidates.toList() + } /** * Шифрование reply blob для передачи по сети @@ -1018,9 +1363,9 @@ object MessageCrypto { fun encryptReplyBlob(replyJson: String, plainKeyAndNonce: ByteArray): String { return try { - // Convert plainKeyAndNonce to string - simulate JS Buffer.toString('utf-8') behavior - // which replaces invalid UTF-8 sequences with U+FFFD - val password = bytesToJsUtf8String(plainKeyAndNonce) + // Desktop runtime parity: App sets window.Buffer from "buffer" polyfill. + // Use the same UTF-8 decoding semantics for password derivation. + val password = bytesToBufferPolyfillUtf8String(plainKeyAndNonce) // Compress with pako (deflate) val deflater = java.util.zip.Deflater() @@ -1063,154 +1408,222 @@ object MessageCrypto { } /** - * Converts bytes to string mimicking JavaScript's Buffer.toString('utf-8') behavior. - * 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! - * + * Desktop runtime parity: Buffer is from npm package "buffer" (feross), + * not Node native decoder in all execution contexts. + * + * This implementation mirrors utf8Slice() from buffer@6: + * - determines bytesPerSequence from first byte + * - on invalid sequence emits U+FFFD and consumes exactly 1 byte + * - produces surrogate pairs for code points > U+FFFF + */ + private fun bytesToBufferPolyfillUtf8String(bytes: ByteArray): String { + val codePoints = ArrayList(bytes.size * 2) + var index = 0 + val end = bytes.size + + while (index < end) { + val firstByte = bytes[index].toInt() and 0xff + var codePoint: Int? = null + var bytesPerSequence = when { + firstByte > 0xef -> 4 + firstByte > 0xdf -> 3 + firstByte > 0xbf -> 2 + else -> 1 + } + + if (index + bytesPerSequence <= end) { + when (bytesPerSequence) { + 1 -> { + if (firstByte < 0x80) { + codePoint = firstByte + } + } + 2 -> { + val secondByte = bytes[index + 1].toInt() and 0xff + if ((secondByte and 0xc0) == 0x80) { + val tempCodePoint = ((firstByte and 0x1f) shl 6) or (secondByte and 0x3f) + if (tempCodePoint > 0x7f) { + codePoint = tempCodePoint + } + } + } + 3 -> { + val secondByte = bytes[index + 1].toInt() and 0xff + val thirdByte = bytes[index + 2].toInt() and 0xff + if ((secondByte and 0xc0) == 0x80 && (thirdByte and 0xc0) == 0x80) { + val tempCodePoint = + ((firstByte and 0x0f) shl 12) or + ((secondByte and 0x3f) shl 6) or + (thirdByte and 0x3f) + if (tempCodePoint > 0x7ff && + (tempCodePoint < 0xd800 || tempCodePoint > 0xdfff) + ) { + codePoint = tempCodePoint + } + } + } + 4 -> { + val secondByte = bytes[index + 1].toInt() and 0xff + val thirdByte = bytes[index + 2].toInt() and 0xff + val fourthByte = bytes[index + 3].toInt() and 0xff + if ((secondByte and 0xc0) == 0x80 && + (thirdByte and 0xc0) == 0x80 && + (fourthByte and 0xc0) == 0x80 + ) { + val tempCodePoint = + ((firstByte and 0x0f) shl 18) or + ((secondByte and 0x3f) shl 12) or + ((thirdByte and 0x3f) shl 6) or + (fourthByte and 0x3f) + if (tempCodePoint > 0xffff && tempCodePoint < 0x110000) { + codePoint = tempCodePoint + } + } + } + } + } + + if (codePoint == null) { + codePoint = 0xfffd + bytesPerSequence = 1 + } else if (codePoint > 0xffff) { + val adjusted = codePoint - 0x10000 + codePoints.add(((adjusted ushr 10) and 0x3ff) or 0xd800) + codePoint = 0xdc00 or (adjusted and 0x3ff) + } + + codePoints.add(codePoint) + index += bytesPerSequence + } + + val builder = StringBuilder(codePoints.size) + codePoints.forEach { builder.append(it.toChar()) } + return builder.toString() + } + + /** + * WHATWG/Node-like UTF-8 decoder fallback. + * Kept for backwards compatibility with already persisted payloads. + * * Public wrapper for use in MessageRepository */ fun bytesToJsUtf8StringPublic(bytes: ByteArray): String = bytesToJsUtf8String(bytes) /** * Converts bytes to string mimicking JavaScript's Buffer.toString('utf-8') behavior. - * 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! + * Implements the WHATWG Encoding Standard UTF-8 decoder algorithm EXACTLY: + * https://encoding.spec.whatwg.org/#utf-8-decoder + * + * CRITICAL RULES (differ from naive implementations): + * 1. Failed multi-byte sequence → emit exactly ONE U+FFFD, reprocess the bad byte + * 2. Truncated sequence at end of input → emit exactly ONE U+FFFD + * 3. Overlong / surrogate rejection via lower/upper continuation bounds (not post-hoc check) + * 4. Bytes 0x80-0xC1, 0xF5-0xFF as starters → ONE U+FFFD per byte + * + * This is critical for cross-platform compatibility with desktop (Node.js)! */ private fun bytesToJsUtf8String(bytes: ByteArray): String { val result = StringBuilder() + + // WHATWG state variables + var codePoint = 0 + var bytesNeeded = 0 + var bytesSeen = 0 + var lowerBoundary = 0x80 + var upperBoundary = 0xBF + var i = 0 - - 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 -> { + // Process all bytes + one extra iteration for end-of-input handling + while (i <= bytes.size) { + if (i == bytes.size) { + // End of input + if (bytesNeeded > 0) { + // Truncated multi-byte sequence → exactly ONE U+FFFD result.append('\uFFFD') - i++ } - - // 2-byte sequence (0xC0-0xDF) - b0 <= 0xDF -> { - if (i + 1 >= bytes.size) { - // Truncated - emit replacement for this byte + break + } + + val b = bytes[i].toInt() and 0xFF + + if (bytesNeeded == 0) { + // Initial state — expecting a starter byte + when { + b <= 0x7F -> { + result.append(b.toChar()) + i++ + } + b in 0xC2..0xDF -> { + bytesNeeded = 1 + codePoint = b and 0x1F + i++ + } + b in 0xE0..0xEF -> { + bytesNeeded = 2 + codePoint = b and 0x0F + // WHATWG: tighter bounds to reject overlong & surrogates early + when (b) { + 0xE0 -> lowerBoundary = 0xA0 // reject overlong < U+0800 + 0xED -> upperBoundary = 0x9F // reject surrogates U+D800..U+DFFF + } + i++ + } + b in 0xF0..0xF4 -> { + bytesNeeded = 3 + codePoint = b and 0x07 + when (b) { + 0xF0 -> lowerBoundary = 0x90 // reject overlong < U+10000 + 0xF4 -> upperBoundary = 0x8F // reject > U+10FFFF + } + i++ + } + else -> { + // 0x80-0xC1, 0xF5-0xFF → invalid starter → ONE U+FFFD per 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 { + // Continuation state — expecting byte in [lowerBoundary, upperBoundary] + if (b in lowerBoundary..upperBoundary) { + // Valid continuation + codePoint = (codePoint shl 6) or (b and 0x3F) + bytesSeen++ + // Reset bounds to default after first continuation + lowerBoundary = 0x80 + upperBoundary = 0xBF + + if (bytesSeen == bytesNeeded) { + // Sequence complete — emit code point + if (codePoint <= 0xFFFF) { + result.append(codePoint.toChar()) } 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 + // Supplementary: surrogate pair + val adjusted = codePoint - 0x10000 + result.append((0xD800 + (adjusted shr 10)).toChar()) + result.append((0xDC00 + (adjusted and 0x3FF)).toChar()) } + // Reset state + codePoint = 0 + bytesNeeded = 0 + bytesSeen = 0 } - } - - // 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++ + } else { + // Invalid continuation → emit exactly ONE U+FFFD for the whole + // failed sequence, then REPROCESS this byte (don't consume it) + result.append('\uFFFD') + // Reset state + codePoint = 0 + bytesNeeded = 0 + bytesSeen = 0 + lowerBoundary = 0x80 + upperBoundary = 0xBF + // Do NOT increment i — reprocess this byte as a potential starter } } } - + return result.toString() } @@ -1235,58 +1648,39 @@ object MessageCrypto { } // Parse ivBase64:ciphertextBase64 - val parts = encryptedBlob.split(':') + val parts = encryptedBlob.split(':', limit = 2) if (parts.size != 2) { return encryptedBlob } - val iv = Base64.decode(parts[0], Base64.DEFAULT) - val ciphertext = Base64.decode(parts[1], Base64.DEFAULT) - - - // Password from plainKeyAndNonce - use same JS-like UTF-8 conversion - val password = bytesToJsUtf8String(plainKeyAndNonce) + val iv = decodeBase64Compat(parts[0]) ?: return encryptedBlob + val ciphertext = decodeBase64Compat(parts[1]) ?: return encryptedBlob - // PBKDF2 key derivation — SHA256 (совместимо с crypto-js и encryptReplyBlob) - val keyBytes = generatePBKDF2Key(password) - - // AES-CBC decryption - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - val keySpec = SecretKeySpec(keyBytes, "AES") - val ivSpec = IvParameterSpec(iv) - cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec) - val compressedBytes = cipher.doFinal(ciphertext) - - inflateToUtf8(compressedBytes) - } catch (e: Exception) { - // Fallback: пробуем SHA1 для обратной совместимости со старыми сообщениями - try { - val parts = encryptedBlob.split(':') - if (parts.size != 2) return encryptedBlob - - val iv = Base64.decode(parts[0], Base64.DEFAULT) - val ciphertext = Base64.decode(parts[1], Base64.DEFAULT) - val password = bytesToJsUtf8String(plainKeyAndNonce) - - val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") - val spec = javax.crypto.spec.PBEKeySpec( - password.toCharArray(), - "rosetta".toByteArray(Charsets.UTF_8), - 1000, - 256 + val passwordCandidates = buildAttachmentPasswordCandidates(plainKeyAndNonce) + for (password in passwordCandidates) { + val passwordBytes = password.toByteArray(Charsets.UTF_8) + val keyCandidates = listOf( + generatePBKDF2KeyFromBytes(passwordBytes), + generatePBKDF2KeySha1FromBytes(passwordBytes) ) - val keyBytesSha1 = factory.generateSecret(spec).encoded - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(keyBytesSha1, "AES"), IvParameterSpec(iv)) - val compressedBytes = cipher.doFinal(ciphertext) - - inflateToUtf8(compressedBytes) - } catch (e2: Exception) { - // Return as-is, might be plain JSON - encryptedBlob + for (keyBytes in keyCandidates) { + try { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val keySpec = SecretKeySpec(keyBytes, "AES") + val ivSpec = IvParameterSpec(iv) + cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec) + val compressedBytes = cipher.doFinal(ciphertext) + return inflateToUtf8(compressedBytes) + } catch (_: Exception) { } + } } + + encryptedBlob + } catch (e: Exception) { + // Return as-is, might be plain JSON + encryptedBlob } } 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 fae7285..60d3b79 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -713,7 +713,8 @@ class MessageRepository private constructor(private val context: Context) { packet.attachments, packet.chachaKey, privateKey, - plainKeyAndNonce + plainKeyAndNonce, + messageId ) // 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя @@ -1414,13 +1415,15 @@ class MessageRepository private constructor(private val context: Context) { attachments: List, encryptedKey: String, privateKey: String, - plainKeyAndNonce: ByteArray? = null + plainKeyAndNonce: ByteArray? = null, + messageId: String = "" ) { val publicKey = currentAccount ?: return for (attachment in attachments) { // Сохраняем только IMAGE, не FILE (файлы загружаются с CDN при необходимости) if (attachment.type == AttachmentType.IMAGE && attachment.blob.isNotEmpty()) { + MessageLogger.logPhotoDecryptStart(messageId, attachment.id, attachment.blob.length) try { // 1. Расшифровываем blob с ChaCha ключом сообщения @@ -1445,9 +1448,17 @@ class MessageRepository private constructor(private val context: Context) { privateKey = privateKey ) - if (saved) {} else {} - } else {} - } catch (e: Exception) {} + if (saved) { + MessageLogger.logPhotoDecryptSuccess(messageId, attachment.id, true) + } else { + MessageLogger.logPhotoSaveFailed(messageId, attachment.id) + } + } else { + MessageLogger.logPhotoDecryptFailed(messageId, attachment.id) + } + } catch (e: Exception) { + MessageLogger.logPhotoDecryptError(messageId, attachment.id, e) + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index b0cab28..1ba0f9b 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -22,6 +22,7 @@ import kotlin.coroutines.resume */ object ProtocolManager { private const val TAG = "ProtocolManager" + private const val MAX_DEBUG_LOGS = 2000 // Server address - same as React Native version private const val SERVER_ADDRESS = "ws://46.28.71.12:3000" @@ -94,8 +95,8 @@ object ProtocolManager { val timestamp = dateFormat.format(Date()) val logLine = "[$timestamp] $message" - // Always keep logs in memory for the Logs screen (capped at 500) - _debugLogs.value = (_debugLogs.value + logLine).takeLast(500) + // Always keep logs in memory for the Logs screen (opened via `...` in chat) + _debugLogs.value = (_debugLogs.value + logLine).takeLast(MAX_DEBUG_LOGS) } fun enableUILogs(enabled: Boolean) { @@ -404,6 +405,10 @@ object ProtocolManager { * [com.rosetta.messenger.push.RosettaFirebaseMessagingService.onNewToken] * when Firebase rotates the token mid-session. * + * On each connect we send UNSUBSCRIBE first to clear any duplicate + * registrations that may have accumulated on the server, then SUBSCRIBE + * once — guaranteeing exactly one active push binding per device. + * * @param forceToken if non-null, use this token instead of reading SharedPreferences * (used by onNewToken which already has the fresh token). */ @@ -422,13 +427,23 @@ object ProtocolManager { return } - val packet = PacketPushNotification().apply { + // 1) UNSUBSCRIBE — clears ALL existing registrations for this token on the server. + // This removes duplicates that may have been created before the dedup fix. + val unsubPacket = PacketPushNotification().apply { + notificationsToken = token + action = PushNotificationAction.UNSUBSCRIBE + } + send(unsubPacket) + addLog("🔕 Push token UNSUBSCRIBE sent (clearing duplicates)") + + // 2) SUBSCRIBE — register exactly once. + val subPacket = PacketPushNotification().apply { notificationsToken = token action = PushNotificationAction.SUBSCRIBE } - send(packet) + send(subPacket) lastSubscribedToken = token - addLog("🔔 Push token subscribe requested on AUTHENTICATED") + addLog("🔔 Push token SUBSCRIBE sent — single registration") } private fun requestSynchronize() { diff --git a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt index b1165d1..3cd1c06 100644 --- a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt +++ b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt @@ -6,6 +6,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build +import android.util.Log import androidx.core.app.NotificationCompat import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage @@ -41,6 +42,11 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { // 🔥 Флаг - приложение в foreground (видимо пользователю) @Volatile var isAppInForeground = false + // Dedup: suppress duplicate pushes from the server within a short window. + // Key = senderPublicKey (or "__simple__"), Value = timestamp of last shown notification. + private const val DEDUP_WINDOW_MS = 10_000L + private val lastNotifTimestamps = java.util.concurrent.ConcurrentHashMap() + /** Уникальный notification ID для каждого чата (по publicKey) */ fun getNotificationIdForChat(senderPublicKey: String): Int { return senderPublicKey.hashCode() and 0x7FFFFFFF // positive int @@ -77,6 +83,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { /** Вызывается когда получено push-уведомление */ override fun onMessageReceived(remoteMessage: RemoteMessage) { super.onMessageReceived(remoteMessage) + Log.d(TAG, "\u2709\ufe0f onMessageReceived: messageId=${remoteMessage.messageId} from=${remoteMessage.from} data=${remoteMessage.data} notif=${remoteMessage.notification?.body}") var handledMessageData = false @@ -147,6 +154,16 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { if (isAppInForeground || !areNotificationsEnabled()) { return } + // Dedup: suppress duplicate pushes from the same sender within DEDUP_WINDOW_MS + val dedupKey = senderPublicKey?.trim().orEmpty().ifEmpty { "__no_sender__" } + val now = System.currentTimeMillis() + val lastTs = lastNotifTimestamps[dedupKey] + if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) { + Log.d(TAG, "\ud83d\udeab Dedup BLOCKED notification for key=$dedupKey, delta=${now - lastTs}ms") + return // duplicate push — skip + } + lastNotifTimestamps[dedupKey] = now + Log.d(TAG, "\u2705 Showing notification for key=$dedupKey") // Desktop parity: suppress notifications during sync (useDialogFiber.ts checks // protocolState != ProtocolState.SYNCHRONIZATION before calling notify()). if (ProtocolManager.syncInProgress.value) { @@ -198,9 +215,20 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { if (isAppInForeground || !areNotificationsEnabled()) { return } + // Dedup: suppress duplicate pushes within DEDUP_WINDOW_MS + val dedupKey = "__simple__" + val now = System.currentTimeMillis() + val lastTs = lastNotifTimestamps[dedupKey] + if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) { + return // duplicate push — skip + } + lastNotifTimestamps[dedupKey] = now createNotificationChannel() + // Deterministic ID — duplicates replace each other instead of stacking + val notifId = (title + body).hashCode() and 0x7FFFFFFF + val intent = Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK @@ -209,7 +237,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { val pendingIntent = PendingIntent.getActivity( this, - 0, + notifId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) @@ -226,7 +254,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.notify(System.currentTimeMillis().toInt(), notification) + notificationManager.notify(notifId, notification) } /** Создать notification channel для Android 8+ */ 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 61be53c..906b12d 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 @@ -14,6 +14,7 @@ import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.* import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.utils.AttachmentFileManager +import com.rosetta.messenger.utils.MessageLogger import com.rosetta.messenger.utils.MessageThrottleManager import java.util.Date import java.util.UUID @@ -218,8 +219,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Отслеживание прочитанных сообщений - храним timestamp последнего прочитанного private var lastReadMessageTimestamp = 0L - // Флаг что read receipt уже отправлен для текущего диалога - private var readReceiptSentForCurrentDialog = false private fun sortMessagesAscending(messages: List): List = messages.sortedWith(chatMessageAscComparator) @@ -580,7 +579,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { hasMoreMessages = true isLoadingMessages = false lastReadMessageTimestamp = 0L - readReceiptSentForCurrentDialog = false subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога isDialogActive = true // 🔥 Диалог активен! @@ -891,6 +889,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } else { dialogDao.updateDialogFromMessages(account, opponent) } + + // 👁️ Отправляем read receipt — как в desktop (marks read + sends packet) + if (isDialogActive && !isSavedMessages) { + sendReadReceiptToOpponent() + } } catch (e: Exception) {} } @@ -1096,7 +1099,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { status = when (entity.delivered) { 0 -> MessageStatus.SENDING - 1 -> MessageStatus.DELIVERED + 1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED 2 -> MessageStatus.SENT 3 -> MessageStatus.READ else -> MessageStatus.SENT @@ -3755,11 +3758,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val privateKey = myPrivateKey ?: return - // Обновляем timestamp последнего прочитанного + // 🔥 Проверяем timestamp ДО отправки — если не изменился, не шлём повторно val lastIncoming = latestIncomingMessage(_messages.value) - if (lastIncoming != null) { - lastReadMessageTimestamp = lastIncoming.timestamp.time - } + if (lastIncoming == null) return + val incomingTs = lastIncoming.timestamp.time + if (incomingTs <= lastReadMessageTimestamp) return viewModelScope.launch(Dispatchers.IO) { try { @@ -3774,8 +3777,27 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } ProtocolManager.send(packet) - readReceiptSentForCurrentDialog = true - } catch (e: Exception) {} + // ✅ Обновляем timestamp ПОСЛЕ успешной отправки + lastReadMessageTimestamp = incomingTs + MessageLogger.logReadReceiptSent(opponent) + } catch (e: Exception) { + MessageLogger.logReadReceiptFailed(opponent, e) + // 🔄 Retry через 2с (desktop буферизует через WebSocket, мы ретраим вручную) + try { + kotlinx.coroutines.delay(2000) + ProtocolManager.send( + PacketRead().apply { + this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey) + fromPublicKey = sender + toPublicKey = opponent + } + ) + lastReadMessageTimestamp = incomingTs + MessageLogger.logReadReceiptSent(opponent, retry = true) + } catch (retryEx: Exception) { + MessageLogger.logReadReceiptFailed(opponent, retryEx, retry = true) + } + } } } @@ -3908,7 +3930,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ProtocolManager.unwaitPacket(0x05, onlinePacketHandler) lastReadMessageTimestamp = 0L - readReceiptSentForCurrentDialog = false subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке opponentKey = null } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index ddd3d1b..157fefe 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -235,7 +235,8 @@ fun ChatsListScreen( avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, onAddAccount: () -> Unit = {}, onSwitchAccount: (String) -> Unit = {}, - onDeleteAccountFromSidebar: (String) -> Unit = {} + onDeleteAccountFromSidebar: (String) -> Unit = {}, + onLogsClick: () -> Unit = {} ) { // Theme transition state var hasInitialized by remember { mutableStateOf(false) } @@ -1148,6 +1149,22 @@ fun ChatsListScreen( } ) + // 📋 Logs + DrawerMenuItemEnhanced( + icon = TablerIcons.Bug, + text = "Logs", + iconColor = menuIconColor, + textColor = menuTextColor, + onClick = { + scope.launch { + drawerState.close() + kotlinx.coroutines + .delay(100) + onLogsClick() + } + } + ) + } // ═══════════════════════════════════════════════════════════ @@ -1840,6 +1857,7 @@ fun ChatsListScreen( pinnedChats ) { chatsState.dialogs + .distinctBy { it.opponentKey } .sortedWith( compareByDescending< DialogUiModel> { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index a9c1d93..d6d46d0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -261,7 +261,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .collect { decryptedDialogs -> - _dialogs.value = decryptedDialogs + // Deduplicate by opponentKey to prevent LazyColumn crash + // (Key "X" was already used) + _dialogs.value = decryptedDialogs.distinctBy { it.opponentKey } // 🚀 Убираем skeleton после первой загрузки if (_isLoading.value) _isLoading.value = false @@ -352,7 +354,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } } .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки - .collect { decryptedRequests -> _requests.value = decryptedRequests } + .collect { decryptedRequests -> + _requests.value = decryptedRequests.distinctBy { it.opponentKey } + } } // 📊 Подписываемся на количество requests diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 733b0af..a920afc 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -70,6 +70,7 @@ import android.content.Intent import android.webkit.MimeTypeMap import java.io.ByteArrayInputStream import java.io.File +import java.security.MessageDigest import kotlin.math.min private const val TAG = "AttachmentComponents" @@ -86,6 +87,15 @@ private fun logPhotoDebug(message: String) { AttachmentDownloadDebugLogger.log(message) } +private fun shortDebugHash(bytes: ByteArray): String { + return try { + val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + digest.copyOfRange(0, 6).joinToString("") { "%02x".format(it) } + } catch (_: Exception) { + "shaerr" + } +} + /** * Анимированный текст с волнообразными точками. * Три точки плавно подпрыгивают каскадом с изменением прозрачности. @@ -1016,13 +1026,13 @@ fun ImageAttachment( downloadStatus = DownloadStatus.ERROR errorLabel = "Error" logPhotoDebug( - "Image download ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}" + "Image download ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}, stack=${e.stackTraceToString().take(200)}" ) } catch (e: OutOfMemoryError) { System.gc() downloadStatus = DownloadStatus.ERROR errorLabel = "Error" - logPhotoDebug("Image OOM: id=$idShort") + logPhotoDebug("Image OOM: id=$idShort, availMem=${Runtime.getRuntime().freeMemory() / 1024}KB") } } } else { @@ -2341,21 +2351,57 @@ private suspend fun processDownloadedImage( onStatus(DownloadStatus.DECRYPTING) // Расшифровываем ключ - val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) - logPhotoDebug("Key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}") + val keyCandidates: List + try { + keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey) + if (keyCandidates.isEmpty()) { + throw IllegalArgumentException("empty key candidates") + } + logPhotoDebug("Key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${keyCandidates.first().size}") + keyCandidates.forEachIndexed { idx, candidate -> + val keyHead = candidate.take(8).joinToString("") { "%02x".format(it.toInt() and 0xff) } + logPhotoDebug("Key material[$idx]: id=$idShort, keyFp=${shortDebugHash(candidate)}, keyHead=$keyHead, keySize=${candidate.size}") + } + } catch (e: Exception) { + onError("Error") + onStatus(DownloadStatus.ERROR) + val keyPrefix = if (chachaKey.startsWith("sync:")) "sync" else "ecdh" + logPhotoDebug("Key decrypt FAILED: id=$idShort, keyType=$keyPrefix, keyLen=${chachaKey.length}, err=${e.javaClass.simpleName}: ${e.message?.take(80)}") + return + } // Расшифровываем контент val decryptStartTime = System.currentTimeMillis() - val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce) + var successKeyIdx = -1 + var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList()) + for ((idx, keyCandidate) in keyCandidates.withIndex()) { + val attempt = MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(encryptedContent, keyCandidate) + if (attempt.decrypted != null) { + successKeyIdx = idx + decryptDebug = attempt + break + } + // Keep last trace for diagnostics if all fail. + decryptDebug = attempt + } + val decrypted = decryptDebug.decrypted val decryptTime = System.currentTimeMillis() - decryptStartTime onProgress(0.8f) if (decrypted != null) { var decodedBitmap: Bitmap? = null var saved = false - logPhotoDebug("Blob decrypt OK: id=$idShort, time=${decryptTime}ms") + logPhotoDebug("Blob decrypt OK: id=$idShort, time=${decryptTime}ms, decryptedLen=${decrypted.length}, keyIdx=$successKeyIdx") + decryptDebug.trace.lastOrNull { it.contains("SUCCESS") }?.let { successLine -> + logPhotoDebug("Blob decrypt trace: id=$idShort, $successLine") + } withContext(Dispatchers.IO) { - decodedBitmap = base64ToBitmap(decrypted) + try { + decodedBitmap = base64ToBitmap(decrypted) + } catch (oom: OutOfMemoryError) { + System.gc() + logPhotoDebug("Bitmap OOM: id=$idShort, decryptedLen=${decrypted.length}") + } if (decodedBitmap != null) { onBitmap(decodedBitmap) ImageBitmapCache.put(cacheKey, decodedBitmap!!) @@ -2376,12 +2422,18 @@ private suspend fun processDownloadedImage( } else { onError("Error") onStatus(DownloadStatus.ERROR) - logPhotoDebug("Image decode FAILED: id=$idShort") + val preview = decrypted.take(30).replace("\n", "") + logPhotoDebug("Image decode FAILED: id=$idShort, decryptedLen=${decrypted.length}, preview=$preview") } } else { onError("Error") onStatus(DownloadStatus.ERROR) - logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms") + val keyPrefix = if (chachaKey.startsWith("sync:")) "sync" else "ecdh" + val firstKeySize = keyCandidates.firstOrNull()?.size ?: -1 + logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms, contentLen=${encryptedContent.length}, keyType=$keyPrefix, keyNonceSize=$firstKeySize, keyCandidates=${keyCandidates.size}") + decryptDebug.trace.take(96).forEachIndexed { index, line -> + logPhotoDebug("Blob decrypt TRACE[$index]: id=$idShort, $line") + } } } @@ -2415,20 +2467,39 @@ internal suspend fun downloadAndDecryptImage( "Helper CDN download OK: id=$idShort, bytes=${encryptedContent.length}" ) - val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) + val keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey) + if (keyCandidates.isEmpty()) return@withContext null + val plainKeyAndNonce = keyCandidates.first() logPhotoDebug( - "Helper key decrypt OK: id=$idShort, keySize=${plainKeyAndNonce.size}" + "Helper key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${plainKeyAndNonce.size}" + ) + logPhotoDebug( + "Helper key material: id=$idShort, keyFp=${shortDebugHash(plainKeyAndNonce)}" ) // Primary path for image attachments - var decrypted = - MessageCrypto.decryptAttachmentBlobWithPlainKey( - encryptedContent, - plainKeyAndNonce - ) + var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList()) + var decrypted: String? = null + for ((idx, keyCandidate) in keyCandidates.withIndex()) { + val attempt = + MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug( + encryptedContent, + keyCandidate + ) + if (attempt.decrypted != null) { + decryptDebug = attempt + decrypted = attempt.decrypted + logPhotoDebug("Helper decrypt OK: id=$idShort, keyIdx=$idx") + break + } + decryptDebug = attempt + } // Fallback for legacy payloads if (decrypted.isNullOrEmpty()) { + decryptDebug.trace.takeLast(12).forEachIndexed { index, line -> + logPhotoDebug("Helper decrypt TRACE[$index]: id=$idShort, $line") + } decrypted = try { MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentDownloadDebugLogger.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentDownloadDebugLogger.kt index 10def86..0d79d66 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentDownloadDebugLogger.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentDownloadDebugLogger.kt @@ -9,7 +9,7 @@ import java.util.Date import java.util.Locale object AttachmentDownloadDebugLogger { - private const val MAX_LOGS = 200 + private const val MAX_LOGS = 1000 private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) private val _logs = MutableStateFlow>(emptyList()) @@ -19,6 +19,9 @@ object AttachmentDownloadDebugLogger { val timestamp = dateFormat.format(Date()) val line = "[$timestamp] 🖼️ $message" _logs.update { current -> (current + line).takeLast(MAX_LOGS) } + // Всегда дублируем в debug logs чата напрямую через ProtocolManager + // (не через MessageLogger, чтобы обойти isEnabled гейт) + com.rosetta.messenger.network.ProtocolManager.addLog("🖼️ $message") } fun clear() { diff --git a/app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt b/app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt index 09fad5e..037d924 100644 --- a/app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt +++ b/app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt @@ -14,8 +14,9 @@ import com.rosetta.messenger.network.ProtocolManager object MessageLogger { private const val TAG = "RosettaMsg" - // Включить/выключить логирование (только в DEBUG) - private val isEnabled: Boolean = android.os.Build.TYPE != "user" + // Всегда включён — вывод идёт только в ProtocolManager.addLog() (in-memory UI), + // не в logcat, безопасно для release + private val isEnabled: Boolean = true /** * Добавить лог в UI (Debug Logs в чате) @@ -252,6 +253,120 @@ object MessageLogger { addToUI(msg) } + /** + * Логирование расшифровки фото (inline blob) + */ + fun logPhotoDecryptStart( + messageId: String, + attachmentId: String, + blobSize: Int + ) { + if (!isEnabled) return + val shortMsgId = messageId.take(8) + val shortAttId = attachmentId.take(8) + val msg = "🖼️ PHOTO DECRYPT | msg:$shortMsgId att:$shortAttId blob:${blobSize}b" + Log.d(TAG, msg) + addToUI(msg) + } + + /** + * Логирование успешной расшифровки фото + */ + fun logPhotoDecryptSuccess( + messageId: String, + attachmentId: String, + saved: Boolean + ) { + if (!isEnabled) return + val shortMsgId = messageId.take(8) + val shortAttId = attachmentId.take(8) + val msg = "✅ PHOTO OK | msg:$shortMsgId att:$shortAttId saved:$saved" + Log.d(TAG, msg) + addToUI(msg) + } + + /** + * Логирование ошибки расшифровки фото (blob null) + */ + fun logPhotoDecryptFailed( + messageId: String, + attachmentId: String + ) { + if (!isEnabled) return + val shortMsgId = messageId.take(8) + val shortAttId = attachmentId.take(8) + val msg = "❌ PHOTO DECRYPT FAIL | msg:$shortMsgId att:$shortAttId (decryptedBlob=null)" + Log.e(TAG, msg) + addToUI(msg) + } + + /** + * Логирование ошибки сохранения фото + */ + fun logPhotoSaveFailed( + messageId: String, + attachmentId: String + ) { + if (!isEnabled) return + val shortMsgId = messageId.take(8) + val shortAttId = attachmentId.take(8) + val msg = "⚠️ PHOTO SAVE FAIL | msg:$shortMsgId att:$shortAttId" + Log.e(TAG, msg) + addToUI(msg) + } + + /** + * Логирование исключения при расшифровке фото + */ + fun logPhotoDecryptError( + messageId: String, + attachmentId: String, + error: Throwable + ) { + if (!isEnabled) return + val shortMsgId = messageId.take(8) + val shortAttId = attachmentId.take(8) + val errMsg = error.message?.take(80) ?: "unknown" + val msg = "❌ PHOTO ERR | msg:$shortMsgId att:$shortAttId err:$errMsg" + Log.e(TAG, msg, error) + addToUI(msg) + } + + /** + * Логирование CDN загрузки фото + */ + fun logPhotoCdnDownload(message: String) { + if (!isEnabled) return + val msg = "🖼️ $message" + Log.d(TAG, msg) + addToUI(msg) + } + + /** + * Логирование успешной отправки read receipt + */ + fun logReadReceiptSent(opponentKey: String, retry: Boolean = false) { + if (!isEnabled) return + val shortKey = opponentKey.take(12) + val retryStr = if (retry) " (retry)" else "" + val msg = "👁 READ RECEIPT SENT$retryStr | to:$shortKey" + Log.d(TAG, msg) + addToUI(msg) + } + + /** + * Логирование ошибки отправки read receipt + */ + fun logReadReceiptFailed(opponentKey: String, error: Throwable, retry: Boolean = false) { + if (!isEnabled) return + val shortKey = opponentKey.take(12) + val errMsg = error.message?.take(50) ?: "unknown" + val retryStr = if (retry) " (retry)" else "" + val msg = "❌ READ RECEIPT FAIL$retryStr | to:$shortKey err:$errMsg" + Log.e(TAG, msg, error) + addToUI(msg) + } + /** * Общий debug лог */