feat: Add fallback transport server and enhance file download handling with blurhash support

This commit is contained in:
k1ngsterr1
2026-01-24 16:04:23 +05:00
parent d5083e60a5
commit 8c87b12c5f
5 changed files with 593 additions and 314 deletions

View File

@@ -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
}
}

View File

@@ -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<List<TransportState>>(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)

View File

@@ -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<Bitmap?>(null) }
var blurhashBitmap by remember { mutableStateOf<Bitmap?>(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 = 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 = {
if (downloadTag.isNotEmpty()) {
scope.launch {
try {
downloadStatus = DownloadStatus.DOWNLOADING
val tag = getDownloadTag(attachment.preview)
if (tag.isEmpty()) {
downloadStatus = DownloadStatus.ERROR
return@launch
}
Log.d(TAG, "📥 Downloading image: ${attachment.id}, tag: $downloadTag")
// Скачиваем зашифрованный контент
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
downloadProgress = 0.5f
val encryptedContent = TransportManager.downloadFile(attachment.id, tag)
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
}
} catch (e: Exception) {
android.util.Log.e("ImageAttachment", "Download failed", e)
Log.e(TAG, "Download failed: ${e.message}", 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 ->
// Фоновый слой - blurhash или placeholder
when {
imageBitmap != null -> {
// Показываем полное изображение
Image(
bitmap = bitmap.asImageBitmap(),
bitmap = imageBitmap!!.asImageBitmap(),
contentDescription = "Image",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
blurhashBitmap != null -> {
// Показываем blurhash placeholder
Image(
bitmap = blurhashBitmap!!.asImageBitmap(),
contentDescription = "Preview",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
DownloadStatus.NOT_DOWNLOADED -> {
else -> {
// Простой placeholder
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
.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
.fillMaxSize()
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)),
.size(48.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
color = PrimaryBlue,
modifier = Modifier.size(32.dp)
modifier = Modifier.size(36.dp),
color = Color.White,
strokeWidth = 3.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)
}
}
}
}
else -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)),
.size(48.dp)
.clip(CircleShape)
.background(Color(0xFFE53935).copy(alpha = 0.8f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = PrimaryBlue)
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,16 +379,15 @@ fun FileAttachment(
}
val download: () -> Unit = {
if (downloadTag.isNotEmpty()) {
scope.launch {
try {
downloadStatus = DownloadStatus.DOWNLOADING
val tag = getDownloadTag(preview)
if (tag.isEmpty()) {
downloadStatus = DownloadStatus.ERROR
return@launch
}
Log.d(TAG, "📥 Downloading file: $fileName")
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
downloadProgress = 0.6f
val encryptedContent = TransportManager.downloadFile(attachment.id, tag)
downloadStatus = DownloadStatus.DECRYPTING
val decrypted = MessageCrypto.decryptAttachmentBlob(
@@ -334,50 +395,103 @@ fun FileAttachment(
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) {
android.util.Log.e("FileAttachment", "Download failed", e)
Log.e(TAG, "❌ File 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
) {
// Фон иконки
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.InsertDriveFile,
contentDescription = null,
tint = PrimaryBlue,
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<Bitmap?>(null) }
var blurhashBitmap by remember { mutableStateOf<Bitmap?>(null) }
val preview = getPreview(attachment.preview)
val downloadTag = getDownloadTag(attachment.preview)
LaunchedEffect(attachment.id) {
downloadStatus = if (attachment.blob.isNotEmpty() && !isDownloadTag(attachment.preview)) {
downloadStatus = when {
attachment.blob.isNotEmpty() && !isDownloadTag(attachment.preview) -> {
DownloadStatus.DOWNLOADED
} else if (isDownloadTag(attachment.preview)) {
}
isDownloadTag(attachment.preview) -> {
DownloadStatus.NOT_DOWNLOADED
} else {
DownloadStatus.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,16 +572,13 @@ fun AvatarAttachment(
}
val download: () -> Unit = {
if (downloadTag.isNotEmpty()) {
scope.launch {
try {
downloadStatus = DownloadStatus.DOWNLOADING
val tag = getDownloadTag(attachment.preview)
if (tag.isEmpty()) {
downloadStatus = DownloadStatus.ERROR
return@launch
}
Log.d(TAG, "📥 Downloading avatar...")
val encryptedContent = TransportManager.downloadFile(attachment.id, tag)
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
downloadStatus = DownloadStatus.DECRYPTING
val decrypted = MessageCrypto.decryptAttachmentBlob(
@@ -492,53 +591,71 @@ fun AvatarAttachment(
withContext(Dispatchers.IO) {
avatarBitmap = base64ToBitmap(decrypted)
}
// Сохраняем аватар в репозиторий
avatarRepository?.saveAvatar(senderPublicKey, decrypted)
downloadStatus = DownloadStatus.DOWNLOADED
Log.d(TAG, "✅ Avatar downloaded and saved")
} else {
downloadStatus = DownloadStatus.ERROR
}
} catch (e: Exception) {
android.util.Log.e("AvatarAttachment", "Download failed", e)
Log.e(TAG, "Avatar 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 ->
when {
avatarBitmap != null -> {
Image(
bitmap = bitmap.asImageBitmap(),
bitmap = avatarBitmap!!.asImageBitmap(),
contentDescription = "Avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
blurhashBitmap != null -> {
Image(
bitmap = blurhashBitmap!!.asImageBitmap(),
contentDescription = "Avatar preview",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
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<Long, String> {
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"

View File

@@ -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-----

View File

@@ -1,6 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- HTTP для локального сервера -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">46.28.71.12</domain>
</domain-config>
<!-- HTTPS с кастомным CA для CDN -->
<domain-config>
<domain includeSubdomains="true">cdn.rosetta-im.com</domain>
<domain includeSubdomains="true">rosetta-im.com</domain>
<trust-anchors>
<certificates src="@raw/globalsign_ca"/>
<certificates src="system"/>
</trust-anchors>
</domain-config>
</network-security-config>