diff --git a/app/src/main/java/com/rosetta/messenger/network/Packets.kt b/app/src/main/java/com/rosetta/messenger/network/Packets.kt index 5677a18..53abbe4 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Packets.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Packets.kt @@ -515,3 +515,24 @@ class PacketPushNotification : Packet() { return stream } } + +/** + * Request Transport packet (ID: 0x0F) + * Запрос адреса транспортного сервера для загрузки/скачивания файлов + */ +class PacketRequestTransport : Packet() { + var transportServer: String = "" + + override fun getPacketId(): Int = 0x0F + + override fun receive(stream: Stream) { + transportServer = stream.readString() + } + + override fun send(): Stream { + val stream = Stream() + stream.writeInt16(getPacketId()) + stream.writeString(transportServer) + return stream + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 6b08027..4ecec84 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -174,6 +174,13 @@ object ProtocolManager { } } } + + // 🚀 Обработчик транспортного сервера (0x0F) + waitPacket(0x0F) { packet -> + val transportPacket = packet as PacketRequestTransport + android.util.Log.d("Protocol", "🚀 Transport server: ${transportPacket.transportServer}") + TransportManager.setTransportServer(transportPacket.transportServer) + } } /** @@ -210,6 +217,12 @@ object ProtocolManager { */ fun authenticate(publicKey: String, privateHash: String) { getProtocol().startHandshake(publicKey, privateHash) + + // 🚀 Запрашиваем транспортный сервер после авторизации + scope.launch { + delay(500) // Даём время на завершение handshake + TransportManager.requestTransportServer() + } } /** diff --git a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt new file mode 100644 index 0000000..03f8852 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt @@ -0,0 +1,202 @@ +package com.rosetta.messenger.network + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +/** + * Состояние загрузки/скачивания файла + */ +data class TransportState( + val id: String, + val progress: Int // 0-100 +) + +/** + * Менеджер транспорта файлов + * Загрузка и скачивание файлов с транспортного сервера + * Совместимо с desktop версией (TransportProvider) + */ +object TransportManager { + private const val TAG = "TransportManager" + + private var transportServer: String? = null + + private val _uploading = MutableStateFlow>(emptyList()) + val uploading: StateFlow> = _uploading.asStateFlow() + + private val _downloading = MutableStateFlow>(emptyList()) + val downloading: StateFlow> = _downloading.asStateFlow() + + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + + /** + * Установить адрес транспортного сервера + */ + fun setTransportServer(server: String) { + transportServer = server + Log.d(TAG, "🚀 Transport server set: $server") + } + + /** + * Получить адрес транспортного сервера + */ + fun getTransportServer(): String? = transportServer + + /** + * Запросить адрес транспортного сервера с сервера протокола + */ + fun requestTransportServer() { + val packet = PacketRequestTransport() + ProtocolManager.sendPacket(packet) + } + + /** + * Загрузить файл на транспортный сервер + * @param id Уникальный ID файла + * @param content Содержимое файла (base64 или бинарные данные) + * @return Tag для скачивания файла + */ + suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) { + val server = transportServer + ?: throw IllegalStateException("Transport server is not set") + + Log.d(TAG, "📤 Uploading file: $id") + + // Добавляем в список загрузок + _uploading.value = _uploading.value + TransportState(id, 0) + + try { + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "file", + id, + content.toRequestBody("application/octet-stream".toMediaType()) + ) + .build() + + val request = Request.Builder() + .url("$server/u") + .post(requestBody) + .build() + + val response = suspendCoroutine { cont -> + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + cont.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + cont.resume(response) + } + }) + } + + if (!response.isSuccessful) { + throw IOException("Upload failed: ${response.code}") + } + + val responseBody = response.body?.string() + ?: throw IOException("Empty response") + + // Parse JSON response to get tag + val tag = org.json.JSONObject(responseBody).getString("t") + + Log.d(TAG, "✅ Upload complete: $id -> $tag") + + // Обновляем прогресс до 100% + _uploading.value = _uploading.value.map { + if (it.id == id) it.copy(progress = 100) else it + } + + tag + } finally { + // Удаляем из списка загрузок + _uploading.value = _uploading.value.filter { it.id != id } + } + } + + /** + * Скачать файл с транспортного сервера + * @param id Уникальный ID файла (для трекинга) + * @param tag Tag файла на сервере + * @return Содержимое файла + */ + suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) { + val server = transportServer + ?: throw IllegalStateException("Transport server is not set") + + Log.d(TAG, "📥 Downloading file: $id (tag: $tag)") + + // Добавляем в список скачиваний + _downloading.value = _downloading.value + TransportState(id, 0) + + try { + val request = Request.Builder() + .url("$server/d/$tag") + .get() + .build() + + val response = suspendCoroutine { cont -> + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + cont.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + cont.resume(response) + } + }) + } + + if (!response.isSuccessful) { + throw IOException("Download failed: ${response.code}") + } + + val content = response.body?.string() + ?: throw IOException("Empty response") + + Log.d(TAG, "✅ Download complete: $id (${content.length} bytes)") + + // Обновляем прогресс до 100% + _downloading.value = _downloading.value.map { + if (it.id == id) it.copy(progress = 100) else it + } + + content + } finally { + // Удаляем из списка скачиваний + _downloading.value = _downloading.value.filter { it.id != id } + } + } + + /** + * Получить прогресс скачивания файла + */ + fun getDownloadProgress(id: String): Int { + return _downloading.value.find { it.id == id }?.progress ?: -1 + } + + /** + * Получить прогресс загрузки файла + */ + fun getUploadProgress(id: String): Int { + return _uploading.value.find { it.id == id }?.progress ?: -1 + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 0892238..69e4d07 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -1612,6 +1612,10 @@ fun ChatDetailScreen( message.id, isSavedMessages = isSavedMessages, + privateKey = + currentUserPrivateKey, + senderPublicKey = + if (message.isOutgoing) currentUserPublicKey else user.publicKey, onLongClick = { if (!isSelectionMode ) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index b1d5d6f..726374f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -719,6 +719,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } else { } + // Парсим все attachments (IMAGE, FILE, AVATAR) + val parsedAttachments = parseAllAttachments(entity.attachments) return ChatMessage( id = entity.messageId, @@ -732,10 +734,50 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { 3 -> MessageStatus.READ else -> MessageStatus.SENT }, - replyData = replyData + replyData = replyData, + attachments = parsedAttachments, + chachaKey = entity.chachaKey ) } + /** + * Парсинг всех attachments из JSON (кроме MESSAGES который обрабатывается отдельно) + */ + private fun parseAllAttachments(attachmentsJson: String): List { + if (attachmentsJson.isEmpty() || attachmentsJson == "[]") { + return emptyList() + } + + return try { + val attachments = JSONArray(attachmentsJson) + val result = mutableListOf() + + for (i in 0 until attachments.length()) { + val attachment = attachments.getJSONObject(i) + val type = attachment.optInt("type", 0) + + // Пропускаем MESSAGES (1) - это reply, обрабатывается отдельно + if (type == 1) continue + + result.add( + MessageAttachment( + id = attachment.optString("id", ""), + blob = attachment.optString("blob", ""), + type = AttachmentType.fromInt(type), + preview = attachment.optString("preview", ""), + width = attachment.optInt("width", 0), + height = attachment.optInt("height", 0) + ) + ) + } + + result + } catch (e: Exception) { + android.util.Log.e("ChatViewModel", "Failed to parse attachments", e) + emptyList() + } + } + /** * Парсинг reply из текста сообщения (fallback формат) * Формат: "🇵 Reply: "текст цитаты"\n\nосновной текст" diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt new file mode 100644 index 0000000..e2bebf8 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -0,0 +1,662 @@ +package com.rosetta.messenger.ui.chats.components + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.rosetta.messenger.crypto.CryptoManager +import com.rosetta.messenger.crypto.MessageCrypto +import com.rosetta.messenger.network.AttachmentType +import com.rosetta.messenger.network.MessageAttachment +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Статус скачивания attachment + */ +enum class DownloadStatus { + DOWNLOADED, + NOT_DOWNLOADED, + PENDING, + DECRYPTING, + DOWNLOADING, + ERROR +} + +/** + * Composable для отображения всех attachments в сообщении + */ +@Composable +fun MessageAttachments( + attachments: List, + chachaKey: String, + privateKey: String, + isOutgoing: Boolean, + isDarkTheme: Boolean, + senderPublicKey: String, + avatarRepository: AvatarRepository? = null, + modifier: Modifier = Modifier +) { + if (attachments.isEmpty()) return + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + attachments.forEach { attachment -> + when (attachment.type) { + AttachmentType.IMAGE -> { + ImageAttachment( + attachment = attachment, + chachaKey = chachaKey, + privateKey = privateKey, + isDarkTheme = isDarkTheme + ) + } + AttachmentType.FILE -> { + FileAttachment( + attachment = attachment, + chachaKey = chachaKey, + privateKey = privateKey, + isDarkTheme = isDarkTheme + ) + } + AttachmentType.AVATAR -> { + AvatarAttachment( + attachment = attachment, + chachaKey = chachaKey, + privateKey = privateKey, + senderPublicKey = senderPublicKey, + avatarRepository = avatarRepository, + isDarkTheme = isDarkTheme + ) + } + AttachmentType.MESSAGES -> { + // MESSAGES обрабатываются отдельно как reply + } + } + } + } +} + +/** + * Image attachment + */ +@Composable +fun ImageAttachment( + attachment: MessageAttachment, + chachaKey: String, + privateKey: String, + isDarkTheme: Boolean +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } + var imageBitmap by remember { mutableStateOf(null) } + var downloadProgress by remember { mutableStateOf(0) } + + // Проверяем статус при загрузке + LaunchedEffect(attachment.id) { + downloadStatus = if (attachment.blob.isNotEmpty() && !isDownloadTag(attachment.preview)) { + // Blob уже есть - сразу декодируем + DownloadStatus.DOWNLOADED + } else if (isDownloadTag(attachment.preview)) { + // Нужно скачать с сервера + DownloadStatus.NOT_DOWNLOADED + } else { + DownloadStatus.DOWNLOADED + } + + // Если скачано - декодируем изображение + if (downloadStatus == DownloadStatus.DOWNLOADED && attachment.blob.isNotEmpty()) { + withContext(Dispatchers.IO) { + imageBitmap = base64ToBitmap(attachment.blob) + } + } + } + + val download: () -> Unit = { + scope.launch { + try { + downloadStatus = DownloadStatus.DOWNLOADING + + val tag = getDownloadTag(attachment.preview) + if (tag.isEmpty()) { + downloadStatus = DownloadStatus.ERROR + return@launch + } + + // Скачиваем с транспортного сервера + val encryptedContent = TransportManager.downloadFile(attachment.id, tag) + + downloadStatus = DownloadStatus.DECRYPTING + + // Расшифровываем с ChaCha ключом + val decrypted = MessageCrypto.decryptAttachmentBlob( + encryptedContent, + chachaKey, + privateKey + ) + + if (decrypted != null) { + withContext(Dispatchers.IO) { + imageBitmap = base64ToBitmap(decrypted) + } + downloadStatus = DownloadStatus.DOWNLOADED + } else { + downloadStatus = DownloadStatus.ERROR + } + } catch (e: Exception) { + android.util.Log.e("ImageAttachment", "Download failed", e) + downloadStatus = DownloadStatus.ERROR + } + } + } + + Card( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { + if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) { + download() + } + }, + colors = CardDefaults.cardColors( + containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8) + ) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 100.dp, max = 300.dp), + contentAlignment = Alignment.Center + ) { + when (downloadStatus) { + DownloadStatus.DOWNLOADED -> { + imageBitmap?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Image", + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Fit + ) + } + } + DownloadStatus.NOT_DOWNLOADED -> { + // Показываем preview (blurhash) если есть + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(16.dp) + ) { + Icon( + Icons.Default.Image, + contentDescription = null, + tint = if (isDarkTheme) Color.White.copy(alpha = 0.7f) else Color.Gray, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = download, + colors = ButtonDefaults.buttonColors( + containerColor = PrimaryBlue + ) + ) { + Icon( + Icons.Default.Download, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Download") + } + } + } + DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(16.dp) + ) { + CircularProgressIndicator( + color = PrimaryBlue, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = if (downloadStatus == DownloadStatus.DECRYPTING) "Decrypting..." else "Downloading...", + fontSize = 12.sp, + color = if (isDarkTheme) Color.White.copy(alpha = 0.7f) else Color.Gray + ) + } + } + DownloadStatus.ERROR -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(16.dp) + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + tint = Color.Red, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text("Download failed", color = Color.Red, fontSize = 12.sp) + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = download) { + Text("Retry") + } + } + } + else -> { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + } +} + +/** + * File attachment + */ +@Composable +fun FileAttachment( + attachment: MessageAttachment, + chachaKey: String, + privateKey: String, + isDarkTheme: Boolean +) { + val scope = rememberCoroutineScope() + var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } + + // Парсим метаданные: "filesize::filename" + val preview = attachment.preview + val parts = preview.split("::") + val (fileSize, fileName) = when { + parts.size >= 3 -> { + // Формат: "UUID::filesize::filename" + 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") + } + + // Проверяем статус + LaunchedEffect(attachment.id) { + downloadStatus = if (isDownloadTag(preview)) { + DownloadStatus.NOT_DOWNLOADED + } else { + DownloadStatus.DOWNLOADED + } + } + + val download: () -> Unit = { + scope.launch { + try { + downloadStatus = DownloadStatus.DOWNLOADING + + val tag = getDownloadTag(preview) + if (tag.isEmpty()) { + downloadStatus = DownloadStatus.ERROR + return@launch + } + + val encryptedContent = TransportManager.downloadFile(attachment.id, tag) + + downloadStatus = DownloadStatus.DECRYPTING + + val decrypted = MessageCrypto.decryptAttachmentBlob( + encryptedContent, + chachaKey, + privateKey + ) + + if (decrypted != null) { + // TODO: Сохранить файл в Downloads + downloadStatus = DownloadStatus.DOWNLOADED + } else { + downloadStatus = DownloadStatus.ERROR + } + } catch (e: Exception) { + android.util.Log.e("FileAttachment", "Download failed", e) + downloadStatus = DownloadStatus.ERROR + } + } + } + + Card( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { + if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) { + download() + } + }, + colors = CardDefaults.cardColors( + containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // File icon + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(8.dp)) + .background(PrimaryBlue.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.InsertDriveFile, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + // File info + Column(modifier = Modifier.weight(1f)) { + Text( + text = fileName, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (isDarkTheme) Color.White else Color.Black, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = formatFileSize(fileSize), + fontSize = 12.sp, + color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Gray + ) + } + + // Download button/status + when (downloadStatus) { + DownloadStatus.NOT_DOWNLOADED -> { + IconButton(onClick = download) { + Icon( + Icons.Default.Download, + contentDescription = "Download", + tint = PrimaryBlue + ) + } + } + DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = PrimaryBlue, + strokeWidth = 2.dp + ) + } + DownloadStatus.DOWNLOADED -> { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Downloaded", + tint = Color(0xFF4CAF50) + ) + } + DownloadStatus.ERROR -> { + IconButton(onClick = download) { + Icon( + Icons.Default.Refresh, + contentDescription = "Retry", + tint = Color.Red + ) + } + } + else -> {} + } + } + } +} + +/** + * Avatar attachment - аватар отправителя + */ +@Composable +fun AvatarAttachment( + attachment: MessageAttachment, + chachaKey: String, + privateKey: String, + senderPublicKey: String, + avatarRepository: AvatarRepository?, + isDarkTheme: Boolean +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } + var avatarBitmap by remember { mutableStateOf(null) } + + LaunchedEffect(attachment.id) { + downloadStatus = if (attachment.blob.isNotEmpty() && !isDownloadTag(attachment.preview)) { + DownloadStatus.DOWNLOADED + } else if (isDownloadTag(attachment.preview)) { + DownloadStatus.NOT_DOWNLOADED + } else { + DownloadStatus.DOWNLOADED + } + + if (downloadStatus == DownloadStatus.DOWNLOADED && attachment.blob.isNotEmpty()) { + withContext(Dispatchers.IO) { + avatarBitmap = base64ToBitmap(attachment.blob) + } + } + } + + val download: () -> Unit = { + scope.launch { + try { + downloadStatus = DownloadStatus.DOWNLOADING + + val tag = getDownloadTag(attachment.preview) + if (tag.isEmpty()) { + downloadStatus = DownloadStatus.ERROR + return@launch + } + + val encryptedContent = TransportManager.downloadFile(attachment.id, tag) + + downloadStatus = DownloadStatus.DECRYPTING + + val decrypted = MessageCrypto.decryptAttachmentBlob( + encryptedContent, + chachaKey, + privateKey + ) + + if (decrypted != null) { + withContext(Dispatchers.IO) { + avatarBitmap = base64ToBitmap(decrypted) + } + // Сохраняем аватар в кэш + avatarRepository?.saveAvatar(senderPublicKey, decrypted) + downloadStatus = DownloadStatus.DOWNLOADED + } else { + downloadStatus = DownloadStatus.ERROR + } + } catch (e: Exception) { + android.util.Log.e("AvatarAttachment", "Download failed", e) + downloadStatus = DownloadStatus.ERROR + } + } + } + + Card( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)), + colors = CardDefaults.cardColors( + containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Avatar preview + Box( + modifier = Modifier + .size(60.dp) + .clip(CircleShape) + .background(if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFD0D0D0)), + contentAlignment = Alignment.Center + ) { + when (downloadStatus) { + DownloadStatus.DOWNLOADED -> { + avatarBitmap?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Avatar", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + } + DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = PrimaryBlue, + strokeWidth = 2.dp + ) + } + else -> { + Icon( + Icons.Default.Person, + contentDescription = null, + tint = if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray, + modifier = Modifier.size(32.dp) + ) + } + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + 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 + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + Icons.Default.Lock, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(14.dp) + ) + } + Text( + text = "An avatar image shared in the message.", + fontSize = 12.sp, + color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Gray + ) + } + + if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) { + Button( + onClick = download, + colors = ButtonDefaults.buttonColors( + containerColor = PrimaryBlue + ), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp) + ) { + Icon( + Icons.Default.Download, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Download", fontSize = 12.sp) + } + } + } + } +} + +// 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 fun isDownloadTag(preview: String): Boolean { + val firstPart = preview.split("::").firstOrNull() ?: return false + return uuidRegex.matches(firstPart) +} + +private fun getDownloadTag(preview: String): String { + val parts = preview.split("::") + if (parts.isNotEmpty() && uuidRegex.matches(parts[0])) { + return parts[0] + } + return "" +} + +private fun base64ToBitmap(base64: String): Bitmap? { + return try { + val cleanBase64 = if (base64.contains(",")) { + base64.substringAfter(",") + } else { + base64 + } + val bytes = Base64.decode(cleanBase64, Base64.DEFAULT) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } catch (e: Exception) { + null + } +} + +private fun formatFileSize(bytes: Long): String { + return when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + bytes < 1024 * 1024 * 1024 -> String.format("%.1f MB", bytes / (1024.0 * 1024.0)) + else -> String.format("%.1f GB", bytes / (1024.0 * 1024.0 * 1024.0)) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 3cb9e09..3a0c4da 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -129,6 +129,8 @@ fun MessageBubble( isSelected: Boolean = false, isHighlighted: Boolean = false, isSavedMessages: Boolean = false, + privateKey: String = "", + senderPublicKey: String = "", onLongClick: () -> Unit = {}, onClick: () -> Unit = {}, onSwipeToReply: () -> Unit = {}, @@ -334,6 +336,21 @@ fun MessageBubble( ) Spacer(modifier = Modifier.height(4.dp)) } + + // 📎 Attachments (IMAGE, FILE, AVATAR) + if (message.attachments.isNotEmpty()) { + MessageAttachments( + attachments = message.attachments, + chachaKey = message.chachaKey, + privateKey = privateKey, + isOutgoing = message.isOutgoing, + isDarkTheme = isDarkTheme, + senderPublicKey = senderPublicKey + ) + if (message.text.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + } + } // Если есть reply - текст слева, время справа на одной строке if (message.replyData != null) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt index 26d2940..8ccf4b1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt @@ -3,6 +3,7 @@ package com.rosetta.messenger.ui.chats.models import androidx.compose.runtime.Stable import com.rosetta.messenger.data.Message import com.rosetta.messenger.network.DeliveryStatus +import com.rosetta.messenger.network.MessageAttachment import com.rosetta.messenger.network.SearchUser import java.util.* @@ -32,7 +33,9 @@ data class ChatMessage( val timestamp: Date, val status: MessageStatus = MessageStatus.SENT, val showDateHeader: Boolean = false, - val replyData: ReplyData? = null + val replyData: ReplyData? = null, + val attachments: List = emptyList(), + val chachaKey: String = "" // Для расшифровки attachments ) /** Message delivery and read status */