feat: Implement file and avatar attachment handling in chat messages
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1612,6 +1612,10 @@ fun ChatDetailScreen(
|
||||
message.id,
|
||||
isSavedMessages =
|
||||
isSavedMessages,
|
||||
privateKey =
|
||||
currentUserPrivateKey,
|
||||
senderPublicKey =
|
||||
if (message.isOutgoing) currentUserPublicKey else user.publicKey,
|
||||
onLongClick = {
|
||||
if (!isSelectionMode
|
||||
) {
|
||||
|
||||
@@ -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основной текст"
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user