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 * Расшифровка MESSAGES attachment blob
* Формат: ivBase64:ciphertextBase64 * Формат: ivBase64:ciphertextBase64
* Использует PBKDF2 + AES-256-CBC + zlib decompression * Использует 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( fun decryptAttachmentBlob(
encryptedData: String, encryptedData: String,
@@ -520,20 +558,16 @@ object MessageCrypto {
myPrivateKey: String myPrivateKey: String
): String? { ): String? {
return try { return try {
android.util.Log.d("MessageCrypto", "🔐 decryptAttachmentBlob: data length=${encryptedData.length}, key length=${encryptedKey.length}")
// 1. Расшифровываем ChaCha ключ (как для сообщений) // 1. Расшифровываем ChaCha ключ (как для сообщений)
val keyAndNonce = decryptKeyFromSender(encryptedKey, myPrivateKey) val keyAndNonce = decryptKeyFromSender(encryptedKey, myPrivateKey)
android.util.Log.d("MessageCrypto", "🔑 Decrypted keyAndNonce: ${keyAndNonce.size} bytes")
// 2. Конвертируем key+nonce в строку используя bytesToJsUtf8String // 2. Используем новую функцию
// чтобы совпадало с JS Buffer.toString('utf-8') который заменяет decryptAttachmentBlobWithPlainKey(encryptedData, keyAndNonce)
// невалидные 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)
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("MessageCrypto", "❌ decryptAttachmentBlob failed: ${e.message}", e)
null null
} }
} }
@@ -559,13 +593,20 @@ object MessageCrypto {
*/ */
private fun decryptWithPBKDF2Key(encryptedData: String, pbkdf2Key: ByteArray): String? { private fun decryptWithPBKDF2Key(encryptedData: String, pbkdf2Key: ByteArray): String? {
return try { 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(":") val parts = encryptedData.split(":")
android.util.Log.d("MessageCrypto", "🔓 Split parts: ${parts.size}")
if (parts.size != 2) { if (parts.size != 2) {
android.util.Log.e("MessageCrypto", "❌ Invalid format: expected 2 parts, got ${parts.size}")
return null return null
} }
val iv = android.util.Base64.decode(parts[0], android.util.Base64.DEFAULT) 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 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 расшифровка // AES-256-CBC расшифровка
val cipher = javax.crypto.Cipher.getInstance("AES/CBC/PKCS5Padding") val cipher = javax.crypto.Cipher.getInstance("AES/CBC/PKCS5Padding")
@@ -573,6 +614,7 @@ object MessageCrypto {
val ivSpec = javax.crypto.spec.IvParameterSpec(iv) val ivSpec = javax.crypto.spec.IvParameterSpec(iv)
cipher.init(javax.crypto.Cipher.DECRYPT_MODE, secretKey, ivSpec) cipher.init(javax.crypto.Cipher.DECRYPT_MODE, secretKey, ivSpec)
val decrypted = cipher.doFinal(ciphertext) val decrypted = cipher.doFinal(ciphertext)
android.util.Log.d("MessageCrypto", "🔓 AES decrypted: ${decrypted.size} bytes")
// Zlib декомпрессия // Zlib декомпрессия
val inflater = java.util.zip.Inflater() val inflater = java.util.zip.Inflater()
@@ -585,8 +627,11 @@ object MessageCrypto {
} }
inflater.end() 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) { } catch (e: Exception) {
android.util.Log.e("MessageCrypto", "❌ decryptWithPBKDF2Key failed: ${e.message}", e)
null null
} }
} }

View File

@@ -31,6 +31,9 @@ data class TransportState(
object TransportManager { object TransportManager {
private const val TAG = "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 var transportServer: String? = null
private val _uploading = MutableStateFlow<List<TransportState>>(emptyList()) 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 для скачивания файла * @return Tag для скачивания файла
*/ */
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) { suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
val server = transportServer val server = getActiveServer()
?: throw IllegalStateException("Transport server is not set")
Log.d(TAG, "📤 Uploading file: $id") Log.d(TAG, "📤 Uploading file: $id to $server")
// Добавляем в список загрузок // Добавляем в список загрузок
_uploading.value = _uploading.value + TransportState(id, 0) _uploading.value = _uploading.value + TransportState(id, 0)
@@ -139,10 +150,9 @@ object TransportManager {
* @return Содержимое файла * @return Содержимое файла
*/ */
suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) { suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) {
val server = transportServer val server = getActiveServer()
?: throw IllegalStateException("Transport server is not set")
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) _downloading.value = _downloading.value + TransportState(id, 0)

View File

@@ -1,8 +1,13 @@
package com.rosetta.messenger.ui.chats.components package com.rosetta.messenger.ui.chats.components
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.util.Base64 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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.repository.AvatarRepository
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.AvatarFileManager
import com.vanniktech.blurhash.BlurHash
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private const val TAG = "AttachmentComponents"
/** /**
* Статус скачивания attachment * Статус скачивания attachment (как в desktop)
*/ */
enum class DownloadStatus { enum class DownloadStatus {
DOWNLOADED, DOWNLOADED,
@@ -66,7 +74,7 @@ fun MessageAttachments(
Column( Column(
modifier = modifier, modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
attachments.forEach { attachment -> attachments.forEach { attachment ->
when (attachment.type) { when (attachment.type) {
@@ -75,6 +83,7 @@ fun MessageAttachments(
attachment = attachment, attachment = attachment,
chachaKey = chachaKey, chachaKey = chachaKey,
privateKey = privateKey, privateKey = privateKey,
isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
} }
@@ -83,6 +92,7 @@ fun MessageAttachments(
attachment = attachment, attachment = attachment,
chachaKey = chachaKey, chachaKey = chachaKey,
privateKey = privateKey, privateKey = privateKey,
isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
} }
@@ -93,6 +103,7 @@ fun MessageAttachments(
privateKey = privateKey, privateKey = privateKey,
senderPublicKey = senderPublicKey, senderPublicKey = senderPublicKey,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
} }
@@ -105,29 +116,61 @@ fun MessageAttachments(
} }
/** /**
* Image attachment - Telegram style * Image attachment - Telegram style с blurhash placeholder
*/ */
@Composable @Composable
fun ImageAttachment( fun ImageAttachment(
attachment: MessageAttachment, attachment: MessageAttachment,
chachaKey: String, chachaKey: String,
privateKey: String, privateKey: String,
isOutgoing: Boolean,
isDarkTheme: Boolean isDarkTheme: Boolean
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
var imageBitmap by remember { mutableStateOf<Bitmap?>(null) } 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) { LaunchedEffect(attachment.id) {
downloadStatus = if (attachment.blob.isNotEmpty() && !isDownloadTag(attachment.preview)) { // Определяем статус
DownloadStatus.DOWNLOADED downloadStatus = when {
} else if (isDownloadTag(attachment.preview)) { attachment.blob.isNotEmpty() && !isDownloadTag(attachment.preview) -> {
DownloadStatus.NOT_DOWNLOADED // Blob уже есть в сообщении (локальный файл)
} else {
DownloadStatus.DOWNLOADED 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()) { if (downloadStatus == DownloadStatus.DOWNLOADED && attachment.blob.isNotEmpty()) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
imageBitmap = base64ToBitmap(attachment.blob) imageBitmap = base64ToBitmap(attachment.blob)
@@ -136,144 +179,164 @@ fun ImageAttachment(
} }
val download: () -> Unit = { val download: () -> Unit = {
if (downloadTag.isNotEmpty()) {
scope.launch { scope.launch {
try { try {
downloadStatus = DownloadStatus.DOWNLOADING downloadStatus = DownloadStatus.DOWNLOADING
val tag = getDownloadTag(attachment.preview) Log.d(TAG, "📥 Downloading image: ${attachment.id}, tag: $downloadTag")
if (tag.isEmpty()) {
downloadStatus = DownloadStatus.ERROR // Скачиваем зашифрованный контент
return@launch val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
} downloadProgress = 0.5f
val encryptedContent = TransportManager.downloadFile(attachment.id, tag)
downloadStatus = DownloadStatus.DECRYPTING downloadStatus = DownloadStatus.DECRYPTING
Log.d(TAG, "🔓 Decrypting image...")
// Расшифровываем
val decrypted = MessageCrypto.decryptAttachmentBlob( val decrypted = MessageCrypto.decryptAttachmentBlob(
encryptedContent, encryptedContent,
chachaKey, chachaKey,
privateKey privateKey
) )
downloadProgress = 0.8f
if (decrypted != null) { if (decrypted != null) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
imageBitmap = base64ToBitmap(decrypted) imageBitmap = base64ToBitmap(decrypted)
} }
downloadProgress = 1f
downloadStatus = DownloadStatus.DOWNLOADED downloadStatus = DownloadStatus.DOWNLOADED
Log.d(TAG, "✅ Image downloaded and decrypted")
} else { } else {
Log.e(TAG, "❌ Decryption returned null")
downloadStatus = DownloadStatus.ERROR downloadStatus = DownloadStatus.ERROR
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("ImageAttachment", "Download failed", e) Log.e(TAG, "Download failed: ${e.message}", e)
downloadStatus = DownloadStatus.ERROR downloadStatus = DownloadStatus.ERROR
} }
} }
} }
}
// Telegram-style: Изображение без Card wrapper // Telegram-style image с blurhash placeholder
Box( Box(
modifier = Modifier modifier = Modifier
.widthIn(min = 200.dp, max = 280.dp) .widthIn(min = 180.dp, max = 260.dp)
.heightIn(min = 150.dp, max = 350.dp) .heightIn(min = 140.dp, max = 300.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(12.dp))
.clickable { .clickable {
if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) { when (downloadStatus) {
download() DownloadStatus.NOT_DOWNLOADED -> download()
DownloadStatus.DOWNLOADED -> {
// TODO: Open image viewer
}
DownloadStatus.ERROR -> download()
else -> {}
} }
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
when (downloadStatus) { // Фоновый слой - blurhash или placeholder
DownloadStatus.DOWNLOADED -> { when {
imageBitmap?.let { bitmap -> imageBitmap != null -> {
// Показываем полное изображение
Image( Image(
bitmap = bitmap.asImageBitmap(), bitmap = imageBitmap!!.asImageBitmap(),
contentDescription = "Image", contentDescription = "Image",
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)), .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8))
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
) )
} }
} }
// Оверлей для статуса скачивания
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 -> { DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
// Индикатор загрузки с прогрессом
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .size(48.dp)
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)), .clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
CircularProgressIndicator( CircularProgressIndicator(
color = PrimaryBlue, modifier = Modifier.size(36.dp),
modifier = Modifier.size(32.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 -> { DownloadStatus.ERROR -> {
Box( // Ошибка с кнопкой повтора
modifier = Modifier
.fillMaxSize()
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)),
contentAlignment = Alignment.Center
) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .size(48.dp)
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)), .clip(CircleShape)
.background(Color(0xFFE53935).copy(alpha = 0.8f)),
contentAlignment = Alignment.Center 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, attachment: MessageAttachment,
chachaKey: String, chachaKey: String,
privateKey: String, privateKey: String,
isOutgoing: Boolean,
isDarkTheme: Boolean isDarkTheme: Boolean
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
var downloadProgress by remember { mutableStateOf(0f) }
val preview = attachment.preview val preview = attachment.preview
val parts = preview.split("::") val downloadTag = getDownloadTag(preview)
val (fileSize, fileName) = when { val (fileSize, fileName) = parseFilePreview(preview)
parts.size >= 3 -> {
val size = parts[1].toLongOrNull() ?: 0L // Анимация прогресса
val name = parts.drop(2).joinToString("::") val animatedProgress by animateFloatAsState(
Pair(size, name) targetValue = downloadProgress,
} animationSpec = tween(durationMillis = 300),
parts.size == 2 -> { label = "progress"
val size = parts[0].toLongOrNull() ?: 0L )
Pair(size, parts[1])
}
else -> Pair(0L, "File")
}
LaunchedEffect(attachment.id) { LaunchedEffect(attachment.id) {
downloadStatus = if (isDownloadTag(preview)) { downloadStatus = if (isDownloadTag(preview)) {
@@ -317,16 +379,15 @@ fun FileAttachment(
} }
val download: () -> Unit = { val download: () -> Unit = {
if (downloadTag.isNotEmpty()) {
scope.launch { scope.launch {
try { try {
downloadStatus = DownloadStatus.DOWNLOADING downloadStatus = DownloadStatus.DOWNLOADING
val tag = getDownloadTag(preview) Log.d(TAG, "📥 Downloading file: $fileName")
if (tag.isEmpty()) {
downloadStatus = DownloadStatus.ERROR val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
return@launch downloadProgress = 0.6f
}
val encryptedContent = TransportManager.downloadFile(attachment.id, tag)
downloadStatus = DownloadStatus.DECRYPTING downloadStatus = DownloadStatus.DECRYPTING
val decrypted = MessageCrypto.decryptAttachmentBlob( val decrypted = MessageCrypto.decryptAttachmentBlob(
@@ -334,50 +395,103 @@ fun FileAttachment(
chachaKey, chachaKey,
privateKey privateKey
) )
downloadProgress = 0.9f
if (decrypted != null) { if (decrypted != null) {
// TODO: Save to Downloads folder
downloadProgress = 1f
downloadStatus = DownloadStatus.DOWNLOADED downloadStatus = DownloadStatus.DOWNLOADED
Log.d(TAG, "✅ File downloaded: $fileName")
} else { } else {
downloadStatus = DownloadStatus.ERROR downloadStatus = DownloadStatus.ERROR
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("FileAttachment", "Download failed", e) Log.e(TAG, "❌ File download failed", e)
downloadStatus = DownloadStatus.ERROR downloadStatus = DownloadStatus.ERROR
} }
} }
} }
}
// Telegram-style: компактный row с иконкой файла // Telegram-style файл
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(if (isDarkTheme) Color(0xFF1F1F1F) else Color.White.copy(0.3f)) .background(
.clickable { if (isOutgoing) {
if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) { Color.White.copy(alpha = 0.15f)
download() } else {
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0)
} }
)
.clickable(enabled = downloadStatus == DownloadStatus.NOT_DOWNLOADED || downloadStatus == DownloadStatus.ERROR) {
download()
} }
.padding(10.dp), .padding(10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// File icon // File icon с индикатором прогресса
Box( Box(
modifier = Modifier modifier = Modifier.size(44.dp),
.size(44.dp)
.clip(RoundedCornerShape(6.dp))
.background(PrimaryBlue.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center 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( Icon(
Icons.Default.InsertDriveFile, Icons.Default.ArrowDownward,
contentDescription = null, contentDescription = "Download",
tint = PrimaryBlue, tint = if (isOutgoing) Color.White else PrimaryBlue,
modifier = Modifier.size(22.dp) 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 // File info
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
@@ -385,59 +499,28 @@ fun FileAttachment(
text = fileName, text = fileName,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, 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, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(2.dp))
Text( Text(
text = formatFileSize(fileSize), text = when (downloadStatus) {
DownloadStatus.DOWNLOADING -> "Downloading..."
DownloadStatus.DECRYPTING -> "Decrypting..."
DownloadStatus.ERROR -> "Download failed"
else -> formatFileSize(fileSize)
},
fontSize = 12.sp, 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 @Composable
fun AvatarAttachment( fun AvatarAttachment(
@@ -446,20 +529,39 @@ fun AvatarAttachment(
privateKey: String, privateKey: String,
senderPublicKey: String, senderPublicKey: String,
avatarRepository: AvatarRepository?, avatarRepository: AvatarRepository?,
isOutgoing: Boolean,
isDarkTheme: Boolean isDarkTheme: Boolean
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
var avatarBitmap by remember { mutableStateOf<Bitmap?>(null) } 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) { LaunchedEffect(attachment.id) {
downloadStatus = if (attachment.blob.isNotEmpty() && !isDownloadTag(attachment.preview)) { downloadStatus = when {
attachment.blob.isNotEmpty() && !isDownloadTag(attachment.preview) -> {
DownloadStatus.DOWNLOADED DownloadStatus.DOWNLOADED
} else if (isDownloadTag(attachment.preview)) { }
isDownloadTag(attachment.preview) -> {
DownloadStatus.NOT_DOWNLOADED 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()) { if (downloadStatus == DownloadStatus.DOWNLOADED && attachment.blob.isNotEmpty()) {
@@ -470,16 +572,13 @@ fun AvatarAttachment(
} }
val download: () -> Unit = { val download: () -> Unit = {
if (downloadTag.isNotEmpty()) {
scope.launch { scope.launch {
try { try {
downloadStatus = DownloadStatus.DOWNLOADING downloadStatus = DownloadStatus.DOWNLOADING
val tag = getDownloadTag(attachment.preview) Log.d(TAG, "📥 Downloading avatar...")
if (tag.isEmpty()) {
downloadStatus = DownloadStatus.ERROR
return@launch
}
val encryptedContent = TransportManager.downloadFile(attachment.id, tag) val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
downloadStatus = DownloadStatus.DECRYPTING downloadStatus = DownloadStatus.DECRYPTING
val decrypted = MessageCrypto.decryptAttachmentBlob( val decrypted = MessageCrypto.decryptAttachmentBlob(
@@ -492,53 +591,71 @@ fun AvatarAttachment(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
avatarBitmap = base64ToBitmap(decrypted) avatarBitmap = base64ToBitmap(decrypted)
} }
// Сохраняем аватар в репозиторий
avatarRepository?.saveAvatar(senderPublicKey, decrypted) avatarRepository?.saveAvatar(senderPublicKey, decrypted)
downloadStatus = DownloadStatus.DOWNLOADED downloadStatus = DownloadStatus.DOWNLOADED
Log.d(TAG, "✅ Avatar downloaded and saved")
} else { } else {
downloadStatus = DownloadStatus.ERROR downloadStatus = DownloadStatus.ERROR
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("AvatarAttachment", "Download failed", e) Log.e(TAG, "Avatar download failed", e)
downloadStatus = DownloadStatus.ERROR downloadStatus = DownloadStatus.ERROR
} }
} }
} }
}
// Telegram-style: компактный row с круглым превью // Telegram-style avatar attachment
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(if (isDarkTheme) Color(0xFF1F1F1F) else Color.White.copy(0.3f)) .background(
.clickable(enabled = downloadStatus == DownloadStatus.NOT_DOWNLOADED) { 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() download()
} }
.padding(10.dp), .padding(10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Avatar preview (круглое) // Avatar preview
Box( Box(
modifier = Modifier modifier = Modifier
.size(50.dp) .size(48.dp)
.clip(CircleShape) .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 contentAlignment = Alignment.Center
) { ) {
when (downloadStatus) { when {
DownloadStatus.DOWNLOADED -> { avatarBitmap != null -> {
avatarBitmap?.let { bitmap ->
Image( Image(
bitmap = bitmap.asImageBitmap(), bitmap = avatarBitmap!!.asImageBitmap(),
contentDescription = "Avatar", contentDescription = "Avatar",
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop 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( CircularProgressIndicator(
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
color = PrimaryBlue, color = if (isOutgoing) Color.White else PrimaryBlue,
strokeWidth = 2.dp strokeWidth = 2.dp
) )
} }
@@ -546,35 +663,45 @@ fun AvatarAttachment(
Icon( Icon(
Icons.Default.Person, Icons.Default.Person,
contentDescription = null, contentDescription = null,
tint = if (isDarkTheme) Color.White.copy(0.4f) else Color.Gray, tint = if (isOutgoing) Color.White.copy(0.5f)
modifier = Modifier.size(28.dp) 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)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = "Avatar", text = "Avatar",
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, 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( Icon(
Icons.Default.Lock, Icons.Default.Lock,
contentDescription = null, contentDescription = null,
tint = if (isDarkTheme) Color.White.copy(0.5f) else Color.Gray, tint = if (isOutgoing) Color.White.copy(0.6f)
modifier = Modifier.size(12.dp) else (if (isDarkTheme) Color.White.copy(0.4f) else Color.Gray),
modifier = Modifier.size(13.dp)
) )
} }
Text( 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, 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) { when (downloadStatus) {
DownloadStatus.NOT_DOWNLOADED -> { DownloadStatus.NOT_DOWNLOADED -> {
Icon( Icon(
Icons.Default.Download, Icons.Default.ArrowDownward,
contentDescription = "Download", contentDescription = "Download",
tint = PrimaryBlue, tint = if (isOutgoing) Color.White else PrimaryBlue,
modifier = Modifier.size(24.dp) modifier = Modifier.size(22.dp)
)
}
DownloadStatus.DOWNLOADED -> {
Icon(
Icons.Default.CheckCircle,
contentDescription = "Downloaded",
tint = Color(0xFF4CAF50),
modifier = Modifier.size(24.dp)
) )
} }
DownloadStatus.ERROR -> { DownloadStatus.ERROR -> {
@@ -601,7 +720,15 @@ fun AvatarAttachment(
Icons.Default.Refresh, Icons.Default.Refresh,
contentDescription = "Retry", contentDescription = "Retry",
tint = Color(0xFFE53935), 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 -> {} else -> {}
@@ -609,15 +736,24 @@ fun AvatarAttachment(
} }
} }
// ================================
// Helper functions // 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}$") 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 { private fun isDownloadTag(preview: String): Boolean {
val firstPart = preview.split("::").firstOrNull() ?: return false val firstPart = preview.split("::").firstOrNull() ?: return false
return uuidRegex.matches(firstPart) return uuidRegex.matches(firstPart)
} }
/**
* Получить download tag из preview
*/
private fun getDownloadTag(preview: String): String { private fun getDownloadTag(preview: String): String {
val parts = preview.split("::") val parts = preview.split("::")
if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) { if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) {
@@ -626,6 +762,47 @@ private fun getDownloadTag(preview: String): String {
return "" 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? { private fun base64ToBitmap(base64: String): Bitmap? {
return try { return try {
val cleanBase64 = if (base64.contains(",")) { val cleanBase64 = if (base64.contains(",")) {
@@ -636,10 +813,14 @@ private fun base64ToBitmap(base64: String): Bitmap? {
val bytes = Base64.decode(cleanBase64, Base64.DEFAULT) val bytes = Base64.decode(cleanBase64, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size) BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to decode base64 to bitmap: ${e.message}")
null null
} }
} }
/**
* Форматирование размера файла
*/
private fun formatFileSize(bytes: Long): String { private fun formatFileSize(bytes: Long): String {
return when { return when {
bytes < 1024 -> "$bytes B" 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"?> <?xml version="1.0" encoding="utf-8"?>
<network-security-config> <network-security-config>
<!-- HTTP для локального сервера -->
<domain-config cleartextTrafficPermitted="true"> <domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">46.28.71.12</domain> <domain includeSubdomains="true">46.28.71.12</domain>
</domain-config> </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> </network-security-config>