feat: Implement file and avatar attachment handling in chat messages

This commit is contained in:
2026-01-24 01:35:49 +05:00
parent 10c78e6231
commit ebfec3d0ba
8 changed files with 966 additions and 2 deletions

View File

@@ -1612,6 +1612,10 @@ fun ChatDetailScreen(
message.id,
isSavedMessages =
isSavedMessages,
privateKey =
currentUserPrivateKey,
senderPublicKey =
if (message.isOutgoing) currentUserPublicKey else user.publicKey,
onLongClick = {
if (!isSelectionMode
) {

View File

@@ -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<MessageAttachment> {
if (attachmentsJson.isEmpty() || attachmentsJson == "[]") {
return emptyList()
}
return try {
val attachments = JSONArray(attachmentsJson)
val result = mutableListOf<MessageAttachment>()
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основной текст"

View File

@@ -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<MessageAttachment>,
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<Bitmap?>(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<Bitmap?>(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))
}
}

View File

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

View File

@@ -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<MessageAttachment> = emptyList(),
val chachaKey: String = "" // Для расшифровки attachments
)
/** Message delivery and read status */