feat: Add fallback transport server and enhance file download handling with blurhash support
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
32
app/src/main/res/raw/globalsign_ca.pem
Normal file
32
app/src/main/res/raw/globalsign_ca.pem
Normal 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-----
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user