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
|
* Расшифровка 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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-----
|
||||||
|
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
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user