feat: Implement file and avatar attachment handling in chat messages

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

View File

@@ -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
}
}

View File

@@ -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()
}
}
/**

View File

@@ -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<List<TransportState>>(emptyList())
val uploading: StateFlow<List<TransportState>> = _uploading.asStateFlow()
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
val downloading: StateFlow<List<TransportState>> = _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<Response> { 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<Response> { 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
}
}

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 */