From 8c87b12c5fa3a050a41101cd29dc52225e47cb4e Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 24 Jan 2026 16:04:23 +0500 Subject: [PATCH] feat: Add fallback transport server and enhance file download handling with blurhash support --- .../rosetta/messenger/crypto/MessageCrypto.kt | 67 +- .../messenger/network/TransportManager.kt | 26 +- .../chats/components/AttachmentComponents.kt | 771 +++++++++++------- app/src/main/res/raw/globalsign_ca.pem | 32 + .../main/res/xml/network_security_config.xml | 11 + 5 files changed, 593 insertions(+), 314 deletions(-) create mode 100644 app/src/main/res/raw/globalsign_ca.pem 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 28858d0..b45bc29 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -513,6 +513,44 @@ object MessageCrypto { * Расшифровка MESSAGES attachment blob * Формат: ivBase64:ciphertextBase64 * Использует PBKDF2 + AES-256-CBC + zlib decompression + * + * @param encryptedData зашифрованный контент (ivBase64:ciphertextBase64) + * @param chachaKeyPlain Уже расшифрованный ChaCha ключ (32 bytes) + */ + fun decryptAttachmentBlobWithPlainKey( + encryptedData: String, + chachaKeyPlain: ByteArray + ): String? { + return try { + android.util.Log.d("MessageCrypto", "🔐 decryptAttachmentBlobWithPlainKey: data length=${encryptedData.length}, key=${chachaKeyPlain.size} bytes") + + // ВАЖНО: Для attachment используем только первые 32 bytes (ChaCha key без nonce) + val keyOnly = chachaKeyPlain.copyOfRange(0, 32) + + // 1. Конвертируем key в строку используя bytesToJsUtf8String + // чтобы совпадало с JS Buffer.toString('utf-8') который заменяет + // невалидные UTF-8 последовательности на U+FFFD + val chachaKeyString = bytesToJsUtf8String(keyOnly) + android.util.Log.d("MessageCrypto", "🔑 ChaCha key string length: ${chachaKeyString.length}") + + // 2. Генерируем PBKDF2 ключ (salt='rosetta', 1000 iterations, sha1) + val pbkdf2Key = generatePBKDF2Key(chachaKeyString) + android.util.Log.d("MessageCrypto", "🔑 PBKDF2 key: ${pbkdf2Key.size} bytes") + + // 3. Расшифровываем AES-256-CBC + val result = decryptWithPBKDF2Key(encryptedData, pbkdf2Key) + android.util.Log.d("MessageCrypto", "✅ Decryption result: ${if (result != null) "success (${result.length} chars)" else "null"}") + result + } catch (e: Exception) { + android.util.Log.e("MessageCrypto", "❌ decryptAttachmentBlobWithPlainKey failed: ${e.message}", e) + null + } + } + + /** + * Расшифровка MESSAGES attachment blob (Legacy - с RSA расшифровкой ключа) + * Формат: ivBase64:ciphertextBase64 + * Использует PBKDF2 + AES-256-CBC + zlib decompression */ fun decryptAttachmentBlob( encryptedData: String, @@ -520,20 +558,16 @@ object MessageCrypto { myPrivateKey: String ): String? { return try { + android.util.Log.d("MessageCrypto", "🔐 decryptAttachmentBlob: data length=${encryptedData.length}, key length=${encryptedKey.length}") + // 1. Расшифровываем ChaCha ключ (как для сообщений) val keyAndNonce = decryptKeyFromSender(encryptedKey, myPrivateKey) + android.util.Log.d("MessageCrypto", "🔑 Decrypted keyAndNonce: ${keyAndNonce.size} bytes") - // 2. Конвертируем key+nonce в строку используя bytesToJsUtf8String - // чтобы совпадало с JS Buffer.toString('utf-8') который заменяет - // невалидные UTF-8 последовательности на U+FFFD - val chachaKeyString = bytesToJsUtf8String(keyAndNonce) - - // 3. Генерируем PBKDF2 ключ (salt='rosetta', 1000 iterations, sha1) - val pbkdf2Key = generatePBKDF2Key(chachaKeyString) - - // 4. Расшифровываем AES-256-CBC - decryptWithPBKDF2Key(encryptedData, pbkdf2Key) + // 2. Используем новую функцию + decryptAttachmentBlobWithPlainKey(encryptedData, keyAndNonce) } catch (e: Exception) { + android.util.Log.e("MessageCrypto", "❌ decryptAttachmentBlob failed: ${e.message}", e) null } } @@ -559,13 +593,20 @@ object MessageCrypto { */ private fun decryptWithPBKDF2Key(encryptedData: String, pbkdf2Key: ByteArray): String? { return try { + android.util.Log.d("MessageCrypto", "🔓 decryptWithPBKDF2Key: data length=${encryptedData.length}") + android.util.Log.d("MessageCrypto", "🔓 First 100 chars: ${encryptedData.take(100)}") + android.util.Log.d("MessageCrypto", "🔓 Contains colon: ${encryptedData.contains(":")}") + val parts = encryptedData.split(":") + android.util.Log.d("MessageCrypto", "🔓 Split parts: ${parts.size}") if (parts.size != 2) { + android.util.Log.e("MessageCrypto", "❌ Invalid format: expected 2 parts, got ${parts.size}") 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) + android.util.Log.d("MessageCrypto", "🔓 IV: ${iv.size} bytes, Ciphertext: ${ciphertext.size} bytes") // AES-256-CBC расшифровка val cipher = javax.crypto.Cipher.getInstance("AES/CBC/PKCS5Padding") @@ -573,6 +614,7 @@ object MessageCrypto { val ivSpec = javax.crypto.spec.IvParameterSpec(iv) cipher.init(javax.crypto.Cipher.DECRYPT_MODE, secretKey, ivSpec) val decrypted = cipher.doFinal(ciphertext) + android.util.Log.d("MessageCrypto", "🔓 AES decrypted: ${decrypted.size} bytes") // Zlib декомпрессия val inflater = java.util.zip.Inflater() @@ -585,8 +627,11 @@ object MessageCrypto { } inflater.end() - String(outputStream.toByteArray(), Charsets.UTF_8) + val result = String(outputStream.toByteArray(), Charsets.UTF_8) + android.util.Log.d("MessageCrypto", "🔓 Decompressed: ${result.length} chars") + result } catch (e: Exception) { + android.util.Log.e("MessageCrypto", "❌ decryptWithPBKDF2Key failed: ${e.message}", e) null } } diff --git a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt index 03f8852..f64fdcf 100644 --- a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt @@ -31,6 +31,9 @@ data class TransportState( object TransportManager { private const val TAG = "TransportManager" + // Fallback transport server (CDN) + private const val FALLBACK_TRANSPORT_SERVER = "https://cdn.rosetta-im.com" + private var transportServer: String? = null private val _uploading = MutableStateFlow>(emptyList()) @@ -54,9 +57,18 @@ object TransportManager { } /** - * Получить адрес транспортного сервера + * Получить адрес транспортного сервера (с fallback) */ - fun getTransportServer(): String? = transportServer + fun getTransportServer(): String = transportServer ?: FALLBACK_TRANSPORT_SERVER + + /** + * Получить активный сервер для скачивания/загрузки + */ + private fun getActiveServer(): String { + val server = transportServer ?: FALLBACK_TRANSPORT_SERVER + Log.d(TAG, "📡 Using transport server: $server (configured: ${transportServer != null})") + return server + } /** * Запросить адрес транспортного сервера с сервера протокола @@ -73,10 +85,9 @@ object TransportManager { * @return Tag для скачивания файла */ suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) { - val server = transportServer - ?: throw IllegalStateException("Transport server is not set") + val server = getActiveServer() - Log.d(TAG, "📤 Uploading file: $id") + Log.d(TAG, "📤 Uploading file: $id to $server") // Добавляем в список загрузок _uploading.value = _uploading.value + TransportState(id, 0) @@ -139,10 +150,9 @@ object TransportManager { * @return Содержимое файла */ suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) { - val server = transportServer - ?: throw IllegalStateException("Transport server is not set") + val server = getActiveServer() - Log.d(TAG, "📥 Downloading file: $id (tag: $tag)") + Log.d(TAG, "📥 Downloading file: $id (tag: $tag) from $server") // Добавляем в список скачиваний _downloading.value = _downloading.value + TransportState(id, 0) 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 5381a39..f9f247c 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 @@ -1,8 +1,13 @@ package com.rosetta.messenger.ui.chats.components +import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Canvas import android.util.Base64 +import android.util.Log +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -32,12 +37,15 @@ import com.rosetta.messenger.network.TransportManager import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.utils.AvatarFileManager +import com.vanniktech.blurhash.BlurHash import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +private const val TAG = "AttachmentComponents" + /** - * Статус скачивания attachment + * Статус скачивания attachment (как в desktop) */ enum class DownloadStatus { DOWNLOADED, @@ -66,7 +74,7 @@ fun MessageAttachments( Column( modifier = modifier, - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(4.dp) ) { attachments.forEach { attachment -> when (attachment.type) { @@ -75,6 +83,7 @@ fun MessageAttachments( attachment = attachment, chachaKey = chachaKey, privateKey = privateKey, + isOutgoing = isOutgoing, isDarkTheme = isDarkTheme ) } @@ -83,6 +92,7 @@ fun MessageAttachments( attachment = attachment, chachaKey = chachaKey, privateKey = privateKey, + isOutgoing = isOutgoing, isDarkTheme = isDarkTheme ) } @@ -93,6 +103,7 @@ fun MessageAttachments( privateKey = privateKey, senderPublicKey = senderPublicKey, avatarRepository = avatarRepository, + isOutgoing = isOutgoing, isDarkTheme = isDarkTheme ) } @@ -105,29 +116,61 @@ fun MessageAttachments( } /** - * Image attachment - Telegram style + * Image attachment - Telegram style с blurhash placeholder */ @Composable fun ImageAttachment( attachment: MessageAttachment, chachaKey: String, privateKey: String, + isOutgoing: Boolean, isDarkTheme: Boolean ) { + val context = LocalContext.current val scope = rememberCoroutineScope() var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } var imageBitmap by remember { mutableStateOf(null) } + var blurhashBitmap by remember { mutableStateOf(null) } + var downloadProgress by remember { mutableStateOf(0f) } + val preview = getPreview(attachment.preview) + val downloadTag = getDownloadTag(attachment.preview) + + // Анимация прогресса + val animatedProgress by animateFloatAsState( + targetValue = downloadProgress, + animationSpec = tween(durationMillis = 300), + label = "progress" + ) + + // Определяем начальный статус и декодируем blurhash LaunchedEffect(attachment.id) { - downloadStatus = if (attachment.blob.isNotEmpty() && !isDownloadTag(attachment.preview)) { - DownloadStatus.DOWNLOADED - } else if (isDownloadTag(attachment.preview)) { - DownloadStatus.NOT_DOWNLOADED - } else { - DownloadStatus.DOWNLOADED + // Определяем статус + downloadStatus = when { + attachment.blob.isNotEmpty() && !isDownloadTag(attachment.preview) -> { + // Blob уже есть в сообщении (локальный файл) + DownloadStatus.DOWNLOADED + } + isDownloadTag(attachment.preview) -> { + // Нужно скачать с CDN + DownloadStatus.NOT_DOWNLOADED + } + else -> DownloadStatus.DOWNLOADED } + // Декодируем blurhash для placeholder (если есть) + if (preview.length >= 20) { + withContext(Dispatchers.IO) { + try { + blurhashBitmap = BlurHash.decode(preview, 200, 200) + } catch (e: Exception) { + Log.e(TAG, "Failed to decode blurhash: ${e.message}") + } + } + } + + // Декодируем изображение если уже скачано if (downloadStatus == DownloadStatus.DOWNLOADED && attachment.blob.isNotEmpty()) { withContext(Dispatchers.IO) { imageBitmap = base64ToBitmap(attachment.blob) @@ -136,144 +179,164 @@ fun ImageAttachment( } val download: () -> Unit = { - scope.launch { - try { - downloadStatus = DownloadStatus.DOWNLOADING - val tag = getDownloadTag(attachment.preview) - if (tag.isEmpty()) { - downloadStatus = DownloadStatus.ERROR - return@launch - } - - val encryptedContent = TransportManager.downloadFile(attachment.id, tag) - downloadStatus = DownloadStatus.DECRYPTING - - val decrypted = MessageCrypto.decryptAttachmentBlob( - encryptedContent, - chachaKey, - privateKey - ) - - if (decrypted != null) { - withContext(Dispatchers.IO) { - imageBitmap = base64ToBitmap(decrypted) + if (downloadTag.isNotEmpty()) { + scope.launch { + try { + downloadStatus = DownloadStatus.DOWNLOADING + Log.d(TAG, "📥 Downloading image: ${attachment.id}, tag: $downloadTag") + + // Скачиваем зашифрованный контент + val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag) + downloadProgress = 0.5f + + downloadStatus = DownloadStatus.DECRYPTING + Log.d(TAG, "🔓 Decrypting image...") + + // Расшифровываем + val decrypted = MessageCrypto.decryptAttachmentBlob( + encryptedContent, + chachaKey, + privateKey + ) + downloadProgress = 0.8f + + if (decrypted != null) { + withContext(Dispatchers.IO) { + imageBitmap = base64ToBitmap(decrypted) + } + downloadProgress = 1f + downloadStatus = DownloadStatus.DOWNLOADED + Log.d(TAG, "✅ Image downloaded and decrypted") + } else { + Log.e(TAG, "❌ Decryption returned null") + downloadStatus = DownloadStatus.ERROR } - downloadStatus = DownloadStatus.DOWNLOADED - } else { + } catch (e: Exception) { + Log.e(TAG, "❌ Download failed: ${e.message}", e) downloadStatus = DownloadStatus.ERROR } - } catch (e: Exception) { - android.util.Log.e("ImageAttachment", "Download failed", e) - downloadStatus = DownloadStatus.ERROR } } } - // Telegram-style: Изображение без Card wrapper + // Telegram-style image с blurhash placeholder Box( modifier = Modifier - .widthIn(min = 200.dp, max = 280.dp) - .heightIn(min = 150.dp, max = 350.dp) - .clip(RoundedCornerShape(8.dp)) + .widthIn(min = 180.dp, max = 260.dp) + .heightIn(min = 140.dp, max = 300.dp) + .clip(RoundedCornerShape(12.dp)) .clickable { - if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) { - download() + when (downloadStatus) { + DownloadStatus.NOT_DOWNLOADED -> download() + DownloadStatus.DOWNLOADED -> { + // TODO: Open image viewer + } + DownloadStatus.ERROR -> download() + else -> {} } }, contentAlignment = Alignment.Center ) { - when (downloadStatus) { - DownloadStatus.DOWNLOADED -> { - imageBitmap?.let { bitmap -> - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = "Image", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } + // Фоновый слой - blurhash или placeholder + when { + imageBitmap != null -> { + // Показываем полное изображение + Image( + bitmap = imageBitmap!!.asImageBitmap(), + contentDescription = "Image", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) } - DownloadStatus.NOT_DOWNLOADED -> { - Box( - modifier = Modifier - .fillMaxSize() - .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - Icons.Default.Download, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(40.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - "Tap to download", - fontSize = 13.sp, - color = if (isDarkTheme) Color.White.copy(0.7f) else Color.Gray - ) - } - } - } - DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { - Box( - modifier = Modifier - .fillMaxSize() - .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - CircularProgressIndicator( - color = PrimaryBlue, - modifier = Modifier.size(32.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = if (downloadStatus == DownloadStatus.DECRYPTING) "Decrypting..." else "Downloading...", - fontSize = 12.sp, - color = if (isDarkTheme) Color.White.copy(0.7f) else Color.Gray - ) - } - } - } - DownloadStatus.ERROR -> { - Box( - modifier = Modifier - .fillMaxSize() - .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - Icons.Default.Error, - contentDescription = null, - tint = Color(0xFFE53935), - modifier = Modifier.size(32.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text("Failed", fontSize = 12.sp, color = Color(0xFFE53935)) - Spacer(modifier = Modifier.height(4.dp)) - TextButton(onClick = download) { - Text("Retry", fontSize = 12.sp, color = PrimaryBlue) - } - } - } + blurhashBitmap != null -> { + // Показываем blurhash placeholder + Image( + bitmap = blurhashBitmap!!.asImageBitmap(), + contentDescription = "Preview", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) } else -> { + // Простой placeholder Box( modifier = Modifier .fillMaxSize() - .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp), color = PrimaryBlue) + .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)) + ) + } + } + + // Оверлей для статуса скачивания + if (downloadStatus != DownloadStatus.DOWNLOADED) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center + ) { + when (downloadStatus) { + DownloadStatus.NOT_DOWNLOADED -> { + // Кнопка скачивания + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.ArrowDownward, + contentDescription = "Download", + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + } + DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { + // Индикатор загрузки с прогрессом + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(36.dp), + color = Color.White, + strokeWidth = 3.dp + ) + } + } + DownloadStatus.ERROR -> { + // Ошибка с кнопкой повтора + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(Color(0xFFE53935).copy(alpha = 0.8f)), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Refresh, + contentDescription = "Retry", + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Expired", + fontSize = 12.sp, + color = Color.White + ) + } + } + else -> {} } } } @@ -288,25 +351,24 @@ fun FileAttachment( attachment: MessageAttachment, chachaKey: String, privateKey: String, + isOutgoing: Boolean, isDarkTheme: Boolean ) { + val context = LocalContext.current val scope = rememberCoroutineScope() var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } + var downloadProgress by remember { mutableStateOf(0f) } val preview = attachment.preview - val parts = preview.split("::") - val (fileSize, fileName) = when { - parts.size >= 3 -> { - val size = parts[1].toLongOrNull() ?: 0L - val name = parts.drop(2).joinToString("::") - Pair(size, name) - } - parts.size == 2 -> { - val size = parts[0].toLongOrNull() ?: 0L - Pair(size, parts[1]) - } - else -> Pair(0L, "File") - } + val downloadTag = getDownloadTag(preview) + val (fileSize, fileName) = parseFilePreview(preview) + + // Анимация прогресса + val animatedProgress by animateFloatAsState( + targetValue = downloadProgress, + animationSpec = tween(durationMillis = 300), + label = "progress" + ) LaunchedEffect(attachment.id) { downloadStatus = if (isDownloadTag(preview)) { @@ -317,67 +379,119 @@ fun FileAttachment( } val download: () -> Unit = { - scope.launch { - try { - downloadStatus = DownloadStatus.DOWNLOADING - val tag = getDownloadTag(preview) - if (tag.isEmpty()) { - downloadStatus = DownloadStatus.ERROR - return@launch - } - - val encryptedContent = TransportManager.downloadFile(attachment.id, tag) - downloadStatus = DownloadStatus.DECRYPTING - - val decrypted = MessageCrypto.decryptAttachmentBlob( - encryptedContent, - chachaKey, - privateKey - ) - - if (decrypted != null) { - downloadStatus = DownloadStatus.DOWNLOADED - } else { + if (downloadTag.isNotEmpty()) { + scope.launch { + try { + downloadStatus = DownloadStatus.DOWNLOADING + Log.d(TAG, "📥 Downloading file: $fileName") + + val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag) + downloadProgress = 0.6f + + downloadStatus = DownloadStatus.DECRYPTING + + val decrypted = MessageCrypto.decryptAttachmentBlob( + encryptedContent, + chachaKey, + privateKey + ) + downloadProgress = 0.9f + + if (decrypted != null) { + // TODO: Save to Downloads folder + downloadProgress = 1f + downloadStatus = DownloadStatus.DOWNLOADED + Log.d(TAG, "✅ File downloaded: $fileName") + } else { + downloadStatus = DownloadStatus.ERROR + } + } catch (e: Exception) { + Log.e(TAG, "❌ File download failed", e) downloadStatus = DownloadStatus.ERROR } - } catch (e: Exception) { - android.util.Log.e("FileAttachment", "Download failed", e) - downloadStatus = DownloadStatus.ERROR } } } - // Telegram-style: компактный row с иконкой файла + // Telegram-style файл Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) - .background(if (isDarkTheme) Color(0xFF1F1F1F) else Color.White.copy(0.3f)) - .clickable { - if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) { - download() + .background( + if (isOutgoing) { + Color.White.copy(alpha = 0.15f) + } else { + if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0) } + ) + .clickable(enabled = downloadStatus == DownloadStatus.NOT_DOWNLOADED || downloadStatus == DownloadStatus.ERROR) { + download() } .padding(10.dp), verticalAlignment = Alignment.CenterVertically ) { - // File icon + // File icon с индикатором прогресса Box( - modifier = Modifier - .size(44.dp) - .clip(RoundedCornerShape(6.dp)) - .background(PrimaryBlue.copy(alpha = 0.15f)), + modifier = Modifier.size(44.dp), contentAlignment = Alignment.Center ) { - Icon( - Icons.Default.InsertDriveFile, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(22.dp) - ) + // Фон иконки + Box( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + .background( + if (isOutgoing) Color.White.copy(0.2f) + else PrimaryBlue.copy(alpha = 0.15f) + ), + contentAlignment = Alignment.Center + ) { + when (downloadStatus) { + DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + color = if (isOutgoing) Color.White else PrimaryBlue, + strokeWidth = 2.5.dp + ) + } + DownloadStatus.NOT_DOWNLOADED -> { + Icon( + Icons.Default.ArrowDownward, + contentDescription = "Download", + tint = if (isOutgoing) Color.White else PrimaryBlue, + modifier = Modifier.size(22.dp) + ) + } + DownloadStatus.DOWNLOADED -> { + Icon( + Icons.Default.InsertDriveFile, + contentDescription = null, + tint = if (isOutgoing) Color.White else PrimaryBlue, + modifier = Modifier.size(22.dp) + ) + } + DownloadStatus.ERROR -> { + Icon( + Icons.Default.ErrorOutline, + contentDescription = "Error", + tint = Color(0xFFE53935), + modifier = Modifier.size(22.dp) + ) + } + else -> { + Icon( + Icons.Default.InsertDriveFile, + contentDescription = null, + tint = if (isOutgoing) Color.White else PrimaryBlue, + modifier = Modifier.size(22.dp) + ) + } + } + } } - Spacer(modifier = Modifier.width(10.dp)) + Spacer(modifier = Modifier.width(12.dp)) // File info Column(modifier = Modifier.weight(1f)) { @@ -385,59 +499,28 @@ fun FileAttachment( text = fileName, fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = if (isDarkTheme) Color.White else Color.Black, + color = if (isOutgoing) Color.White else (if (isDarkTheme) Color.White else Color.Black), maxLines = 1, overflow = TextOverflow.Ellipsis ) + Spacer(modifier = Modifier.height(2.dp)) Text( - text = formatFileSize(fileSize), + text = when (downloadStatus) { + DownloadStatus.DOWNLOADING -> "Downloading..." + DownloadStatus.DECRYPTING -> "Decrypting..." + DownloadStatus.ERROR -> "Download failed" + else -> formatFileSize(fileSize) + }, fontSize = 12.sp, - color = if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray + color = if (isOutgoing) Color.White.copy(alpha = 0.7f) + else (if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray) ) } - - Spacer(modifier = Modifier.width(8.dp)) - - // Download/status icon - when (downloadStatus) { - DownloadStatus.NOT_DOWNLOADED -> { - Icon( - Icons.Default.Download, - contentDescription = "Download", - tint = PrimaryBlue, - modifier = Modifier.size(24.dp) - ) - } - DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = PrimaryBlue, - strokeWidth = 2.dp - ) - } - DownloadStatus.DOWNLOADED -> { - Icon( - Icons.Default.CheckCircle, - contentDescription = "Downloaded", - tint = Color(0xFF4CAF50), - modifier = Modifier.size(24.dp) - ) - } - DownloadStatus.ERROR -> { - Icon( - Icons.Default.Refresh, - contentDescription = "Retry", - tint = Color(0xFFE53935), - modifier = Modifier.size(24.dp) - ) - } - else -> {} - } } } /** - * Avatar attachment - Telegram style (более компактный) + * Avatar attachment - Telegram style */ @Composable fun AvatarAttachment( @@ -446,20 +529,39 @@ fun AvatarAttachment( privateKey: String, senderPublicKey: String, avatarRepository: AvatarRepository?, + isOutgoing: Boolean, isDarkTheme: Boolean ) { + val context = LocalContext.current val scope = rememberCoroutineScope() var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } var avatarBitmap by remember { mutableStateOf(null) } + var blurhashBitmap by remember { mutableStateOf(null) } + + val preview = getPreview(attachment.preview) + val downloadTag = getDownloadTag(attachment.preview) LaunchedEffect(attachment.id) { - downloadStatus = if (attachment.blob.isNotEmpty() && !isDownloadTag(attachment.preview)) { - DownloadStatus.DOWNLOADED - } else if (isDownloadTag(attachment.preview)) { - DownloadStatus.NOT_DOWNLOADED - } else { - DownloadStatus.DOWNLOADED + downloadStatus = when { + attachment.blob.isNotEmpty() && !isDownloadTag(attachment.preview) -> { + DownloadStatus.DOWNLOADED + } + isDownloadTag(attachment.preview) -> { + DownloadStatus.NOT_DOWNLOADED + } + else -> DownloadStatus.DOWNLOADED + } + + // Decode blurhash + if (preview.length >= 20) { + withContext(Dispatchers.IO) { + try { + blurhashBitmap = BlurHash.decode(preview, 100, 100) + } catch (e: Exception) { + Log.e(TAG, "Failed to decode avatar blurhash: ${e.message}") + } + } } if (downloadStatus == DownloadStatus.DOWNLOADED && attachment.blob.isNotEmpty()) { @@ -470,75 +572,90 @@ fun AvatarAttachment( } val download: () -> Unit = { - scope.launch { - try { - downloadStatus = DownloadStatus.DOWNLOADING - val tag = getDownloadTag(attachment.preview) - if (tag.isEmpty()) { - downloadStatus = DownloadStatus.ERROR - return@launch - } - - val encryptedContent = TransportManager.downloadFile(attachment.id, tag) - downloadStatus = DownloadStatus.DECRYPTING - - val decrypted = MessageCrypto.decryptAttachmentBlob( - encryptedContent, - chachaKey, - privateKey - ) - - if (decrypted != null) { - withContext(Dispatchers.IO) { - avatarBitmap = base64ToBitmap(decrypted) + if (downloadTag.isNotEmpty()) { + scope.launch { + try { + downloadStatus = DownloadStatus.DOWNLOADING + Log.d(TAG, "📥 Downloading avatar...") + + val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag) + downloadStatus = DownloadStatus.DECRYPTING + + val decrypted = MessageCrypto.decryptAttachmentBlob( + encryptedContent, + chachaKey, + privateKey + ) + + if (decrypted != null) { + withContext(Dispatchers.IO) { + avatarBitmap = base64ToBitmap(decrypted) + } + // Сохраняем аватар в репозиторий + avatarRepository?.saveAvatar(senderPublicKey, decrypted) + downloadStatus = DownloadStatus.DOWNLOADED + Log.d(TAG, "✅ Avatar downloaded and saved") + } else { + downloadStatus = DownloadStatus.ERROR } - avatarRepository?.saveAvatar(senderPublicKey, decrypted) - downloadStatus = DownloadStatus.DOWNLOADED - } else { + } catch (e: Exception) { + Log.e(TAG, "❌ Avatar download failed", e) downloadStatus = DownloadStatus.ERROR } - } catch (e: Exception) { - android.util.Log.e("AvatarAttachment", "Download failed", e) - downloadStatus = DownloadStatus.ERROR } } } - // Telegram-style: компактный row с круглым превью + // Telegram-style avatar attachment Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) - .background(if (isDarkTheme) Color(0xFF1F1F1F) else Color.White.copy(0.3f)) - .clickable(enabled = downloadStatus == DownloadStatus.NOT_DOWNLOADED) { + .background( + if (isOutgoing) { + Color.White.copy(alpha = 0.15f) + } else { + if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0) + } + ) + .clickable(enabled = downloadStatus == DownloadStatus.NOT_DOWNLOADED || downloadStatus == DownloadStatus.ERROR) { download() } .padding(10.dp), verticalAlignment = Alignment.CenterVertically ) { - // Avatar preview (круглое) + // Avatar preview Box( modifier = Modifier - .size(50.dp) + .size(48.dp) .clip(CircleShape) - .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)), + .background( + if (isOutgoing) Color.White.copy(0.2f) + else (if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)) + ), contentAlignment = Alignment.Center ) { - when (downloadStatus) { - DownloadStatus.DOWNLOADED -> { - avatarBitmap?.let { bitmap -> - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = "Avatar", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } + when { + avatarBitmap != null -> { + Image( + bitmap = avatarBitmap!!.asImageBitmap(), + contentDescription = "Avatar", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) } - DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { + blurhashBitmap != null -> { + Image( + bitmap = blurhashBitmap!!.asImageBitmap(), + contentDescription = "Avatar preview", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + downloadStatus == DownloadStatus.DOWNLOADING || downloadStatus == DownloadStatus.DECRYPTING -> { CircularProgressIndicator( modifier = Modifier.size(20.dp), - color = PrimaryBlue, + color = if (isOutgoing) Color.White else PrimaryBlue, strokeWidth = 2.dp ) } @@ -546,35 +663,45 @@ fun AvatarAttachment( Icon( Icons.Default.Person, contentDescription = null, - tint = if (isDarkTheme) Color.White.copy(0.4f) else Color.Gray, - modifier = Modifier.size(28.dp) + tint = if (isOutgoing) Color.White.copy(0.5f) + else (if (isDarkTheme) Color.White.copy(0.4f) else Color.Gray), + modifier = Modifier.size(26.dp) ) } } } - Spacer(modifier = Modifier.width(10.dp)) + Spacer(modifier = Modifier.width(12.dp)) + // Info Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text = "Avatar", fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = if (isDarkTheme) Color.White else Color.Black + color = if (isOutgoing) Color.White else (if (isDarkTheme) Color.White else Color.Black) ) - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(6.dp)) Icon( Icons.Default.Lock, contentDescription = null, - tint = if (isDarkTheme) Color.White.copy(0.5f) else Color.Gray, - modifier = Modifier.size(12.dp) + tint = if (isOutgoing) Color.White.copy(0.6f) + else (if (isDarkTheme) Color.White.copy(0.4f) else Color.Gray), + modifier = Modifier.size(13.dp) ) } Text( - text = "Profile photo shared", + text = when (downloadStatus) { + DownloadStatus.DOWNLOADING -> "Downloading..." + DownloadStatus.DECRYPTING -> "Decrypting..." + DownloadStatus.ERROR -> "Download failed" + DownloadStatus.DOWNLOADED -> "Profile photo shared" + else -> "Tap to download" + }, fontSize = 12.sp, - color = if (isDarkTheme) Color.White.copy(0.5f) else Color.Gray + color = if (isOutgoing) Color.White.copy(alpha = 0.7f) + else (if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray) ) } @@ -582,18 +709,10 @@ fun AvatarAttachment( when (downloadStatus) { DownloadStatus.NOT_DOWNLOADED -> { Icon( - Icons.Default.Download, + Icons.Default.ArrowDownward, contentDescription = "Download", - tint = PrimaryBlue, - modifier = Modifier.size(24.dp) - ) - } - DownloadStatus.DOWNLOADED -> { - Icon( - Icons.Default.CheckCircle, - contentDescription = "Downloaded", - tint = Color(0xFF4CAF50), - modifier = Modifier.size(24.dp) + tint = if (isOutgoing) Color.White else PrimaryBlue, + modifier = Modifier.size(22.dp) ) } DownloadStatus.ERROR -> { @@ -601,7 +720,15 @@ fun AvatarAttachment( Icons.Default.Refresh, contentDescription = "Retry", tint = Color(0xFFE53935), - modifier = Modifier.size(24.dp) + modifier = Modifier.size(22.dp) + ) + } + DownloadStatus.DOWNLOADED -> { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Downloaded", + tint = Color(0xFF4CAF50), + modifier = Modifier.size(22.dp) ) } else -> {} @@ -609,15 +736,24 @@ fun AvatarAttachment( } } +// ================================ // Helper functions +// ================================ private val uuidRegex = Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") +/** + * Проверка является ли preview UUID тегом для скачивания + * Как в desktop: attachment.preview.split("::")[0].match(uuidRegex) + */ private fun isDownloadTag(preview: String): Boolean { val firstPart = preview.split("::").firstOrNull() ?: return false return uuidRegex.matches(firstPart) } +/** + * Получить download tag из preview + */ private fun getDownloadTag(preview: String): String { val parts = preview.split("::") if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) { @@ -626,6 +762,47 @@ private fun getDownloadTag(preview: String): String { return "" } +/** + * Получить preview без download tag + * Как в desktop: preview.split("::").splice(1).join("::") + */ +private fun getPreview(preview: String): String { + val parts = preview.split("::") + if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) { + return parts.drop(1).joinToString("::") + } + return preview +} + +/** + * Парсинг preview для файлов + * Формат: "UUID::filesize::filename" или "filesize::filename" + */ +private fun parseFilePreview(preview: String): Pair { + val parts = preview.split("::") + return when { + parts.size >= 3 && uuidRegex.matches(parts[0]) -> { + // UUID::size::filename + val size = parts[1].toLongOrNull() ?: 0L + val name = parts.drop(2).joinToString("::") + Pair(size, name) + } + parts.size >= 2 && !uuidRegex.matches(parts[0]) -> { + // size::filename + val size = parts[0].toLongOrNull() ?: 0L + Pair(size, parts.drop(1).joinToString("::")) + } + parts.size >= 2 -> { + // UUID::filename (no size) + Pair(0L, parts.drop(1).joinToString("::")) + } + else -> Pair(0L, "File") + } +} + +/** + * Декодирование base64 в Bitmap + */ private fun base64ToBitmap(base64: String): Bitmap? { return try { val cleanBase64 = if (base64.contains(",")) { @@ -636,10 +813,14 @@ private fun base64ToBitmap(base64: String): Bitmap? { val bytes = Base64.decode(cleanBase64, Base64.DEFAULT) BitmapFactory.decodeByteArray(bytes, 0, bytes.size) } catch (e: Exception) { + Log.e(TAG, "Failed to decode base64 to bitmap: ${e.message}") null } } +/** + * Форматирование размера файла + */ private fun formatFileSize(bytes: Long): String { return when { bytes < 1024 -> "$bytes B" diff --git a/app/src/main/res/raw/globalsign_ca.pem b/app/src/main/res/raw/globalsign_ca.pem new file mode 100644 index 0000000..c000fdd --- /dev/null +++ b/app/src/main/res/raw/globalsign_ca.pem @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFjTCCA3WgAwIBAgIRAIN9TriekS/nLK07x2kt3CAwDQYJKoZIhvcNAQELBQAw +TDEgMB4GA1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjYxEzARBgNVBAoTCkds +b2JhbFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMjUwNTIxMDIzNjUyWhcN +MjcwNTIxMDAwMDAwWjBVMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2ln +biBudi1zYTErMCkGA1UEAxMiR2xvYmFsU2lnbiBHQ0MgUjYgQWxwaGFTU0wgQ0Eg +MjAyNTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJ/oiu0Bviq52UUE +ADbFWmgu3rC7KDSMoorLN1Wd03McG3Z1aP71DlPCE33838r72Dfuj5M9LXfiQLJp +Au6MwNExmKOzothw4x0zGf5oBYyrCMGm3fBpLPafwYQ3MchBOWMTbf83rKUPLH48 +KCJ0MnU8GUl8oA/J81wIvbbKPuNrFf6hvJDccjzc4NyxLz3A89zjV2g5whCg5O0u +9YX4Zxk9JHuc/LvllOJO4waAYLjbWBJkz3rV3ts1SmSYnJqmyRTIjXwQgRvhEYqt +DbRskt0W7M6cPwCze3GTBN2UHNpHkMs3YmVxku68I0aOQn5+uz//fDROP3z1Z/7I +APteRtECAwEAAaOCAV8wggFbMA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUEFjAUBggr +BgEFBQcDAQYIKwYBBQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU +xbSTj28r3B5Iv7cQMIXO0bK7SC0wHwYDVR0jBBgwFoAUrmwFo5MT4qLn4tcc1sfw +f8hnU6AwewYIKwYBBQUHAQEEbzBtMC4GCCsGAQUFBzABhiJodHRwOi8vb2NzcDIu +Z2xvYmFsc2lnbi5jb20vcm9vdHI2MDsGCCsGAQUFBzAChi9odHRwOi8vc2VjdXJl +Lmdsb2JhbHNpZ24uY29tL2NhY2VydC9yb290LXI2LmNydDA2BgNVHR8ELzAtMCug +KaAnhiVodHRwOi8vY3JsLmdsb2JhbHNpZ24uY29tL3Jvb3QtcjYuY3JsMCEGA1Ud +IAQaMBgwCAYGZ4EMAQIBMAwGCisGAQQBoDIKAQMwDQYJKoZIhvcNAQELBQADggIB +AB/uvBuZf4CiuSahwiXn4geF52roAH+6jxsEPTXTfb7bbeMDXsYgRRsOTNA70ruZ +Tnz5DfFMuBhNoFhIFb0qR1izdy6VkdKOqFPNF2dOFI1EcnY9l2ory9mrzHqVbrL4 +vzUd17FLUVyjTVU7PAv4nxyhnO1GTeT83YlrdRF31NyR6bvZVTEERHmpbWSgeveJ +LRtaMzlGWiLZ8IwkH7o6GH3jp/KPtDW4Npu8w64HrRZdN2pqQhi7+YKwfHM7H+2U +dM1BGN0sjOWMVbMSB9MtCsleS2Mb7TRZEbOHxECJLLIluQypZr7Pol3+hAqrhyKI +k+6y+Da0NeDuWxW59Ku4NvClqW1UFX1SpfNGhzVfp/CH+vPM1tySomx2jE0EnYZu +GwVucXPBsp5nUWqUV9+143glVuS7GTg9hFPjNBInn17HbCoIIQIOzj5Vd9bK3A9U +GxXNpwenDHEalCsD/4eQYDHPhFE7sNe0D/OXu+FAM02VZkARx37Jp4bDdujvgL9P +vZPR3wThvDN1CTU8Bc3xea3yKFAraKcPZLkhReQUAm2VpR+HSJRPlUpYizlF9WkL +h3KcAVCBJWvnOkVwxyU5QJMcnwW95JlOtx+9100GL99jHE5rs3gXp7F4bg8H01QT +9jVOhBBmQ7nQoXuwI0tqal2QUqZz3eeu62CU7xBwtfYR +-----END CERTIFICATE----- diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 449c01c..cbe4fd4 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,6 +1,17 @@ + 46.28.71.12 + + + + cdn.rosetta-im.com + rosetta-im.com + + + + +