feat: Implement avatar sending functionality and update MediaPicker with avatar selection option

This commit is contained in:
k1ngsterr1
2026-01-26 20:45:46 +05:00
parent 91eb8a4b63
commit 98ccb9078d
5 changed files with 270 additions and 11 deletions

View File

@@ -1967,6 +1967,7 @@ fun ChatDetailScreen(
isVisible = showMediaPicker,
onDismiss = { showMediaPicker = false },
isDarkTheme = isDarkTheme,
currentUserPublicKey = currentUserPublicKey,
onMediaSelected = { selectedMedia ->
// 📸 Отправляем выбранные изображения как коллаж (группу)
android.util.Log.d("ChatDetailScreen", "📸 Sending ${selectedMedia.size} media items as group")
@@ -2015,6 +2016,11 @@ fun ChatDetailScreen(
onOpenFilePicker = {
// 📄 Открываем файловый пикер
filePickerLauncher.launch("*/*")
},
onAvatarClick = {
// 👤 Отправляем свой аватар (как в desktop)
android.util.Log.d("ChatDetailScreen", "👤 Sending avatar...")
viewModel.sendAvatarMessage()
}
)
}

View File

@@ -1,6 +1,9 @@
package com.rosetta.messenger.ui.chats
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
@@ -797,8 +800,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attachmentId = attachment.optString("id", "")
val attachmentType = AttachmentType.fromInt(type)
// 💾 Для IMAGE - пробуем загрузить blob из файла если пустой
if (attachmentType == AttachmentType.IMAGE && blob.isEmpty() && attachmentId.isNotEmpty()) {
// 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой
if ((attachmentType == AttachmentType.IMAGE || attachmentType == AttachmentType.AVATAR)
&& blob.isEmpty() && attachmentId.isNotEmpty()) {
val fileBlob = AttachmentFileManager.readAttachment(
context = getApplication(),
attachmentId = attachmentId,
@@ -1809,6 +1813,219 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
/**
* Отправка аватарки пользователя
* По аналогии с desktop - отправляет текущий аватар как вложение
*/
fun sendAvatarMessage() {
val recipient = opponentKey
val sender = myPublicKey
val userPrivateKey = myPrivateKey
if (recipient == null || sender == null || userPrivateKey == null) {
Log.e(TAG, "👤 Cannot send avatar: missing keys")
return
}
if (isSending) {
Log.w(TAG, "👤 Already sending message")
return
}
isSending = true
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
viewModelScope.launch(Dispatchers.IO) {
try {
Log.d(TAG, "👤 Fetching current user avatar...")
// Получаем свой аватар из AvatarRepository
val avatarDao = database.avatarDao()
val myAvatar = avatarDao.getLatestAvatar(sender)
if (myAvatar == null) {
Log.w(TAG, "👤 No avatar found for current user")
withContext(Dispatchers.Main) {
android.widget.Toast.makeText(
getApplication(),
"No avatar to send",
android.widget.Toast.LENGTH_SHORT
).show()
}
isSending = false
return@launch
}
Log.d(TAG, "👤 Found avatar, path: ${myAvatar.avatar}")
// Читаем и расшифровываем аватар
val avatarBlob = com.rosetta.messenger.utils.AvatarFileManager.readAvatar(
getApplication(),
myAvatar.avatar
)
if (avatarBlob == null || avatarBlob.isEmpty()) {
Log.w(TAG, "👤 Avatar blob is null or empty!")
withContext(Dispatchers.Main) {
android.widget.Toast.makeText(
getApplication(),
"Failed to read avatar",
android.widget.Toast.LENGTH_SHORT
).show()
}
isSending = false
return@launch
}
Log.d(TAG, "👤 Avatar blob read successfully, length: ${avatarBlob.length}")
// Генерируем blurhash для preview (как на desktop)
val avatarBlurhash = withContext(Dispatchers.IO) {
try {
val bitmap = base64ToBitmap(avatarBlob)
if (bitmap != null) {
com.rosetta.messenger.utils.MediaUtils.generateBlurhashFromBitmap(bitmap)
} else {
""
}
} catch (e: Exception) {
Log.e(TAG, "Failed to generate blurhash for avatar: ${e.message}")
""
}
}
Log.d(TAG, "👤 Avatar blurhash generated: ${avatarBlurhash.take(20)}...")
// 1. 🚀 Optimistic UI
val optimisticMessage = ChatMessage(
id = messageId,
text = "",
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
attachments = listOf(
MessageAttachment(
id = "avatar_$timestamp",
type = AttachmentType.AVATAR,
preview = avatarBlurhash,
blob = avatarBlob // Для локального отображения
)
)
)
withContext(Dispatchers.Main) {
_messages.value = _messages.value + optimisticMessage
}
// 2. Шифрование текста (пустой текст для аватарки)
val encryptResult = MessageCrypto.encryptForSending("", recipient)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val privateKeyHash = CryptoManager.generatePrivateKeyHash(userPrivateKey)
// 🔥 КРИТИЧНО: Как в desktop - шифруем аватар с ChaCha ключом (plainKeyAndNonce)
// НЕ с AVATAR_PASSWORD! AVATAR_PASSWORD используется только для локального хранения
val encryptedAvatarBlob = MessageCrypto.encryptReplyBlob(avatarBlob, plainKeyAndNonce)
Log.d(TAG, "👤 Avatar encrypted with ChaCha key, length: ${encryptedAvatarBlob.length}")
val avatarAttachmentId = "avatar_$timestamp"
// 📤 Загружаем на Transport Server (как в desktop!)
val isSavedMessages = (sender == recipient)
var uploadTag = ""
if (!isSavedMessages) {
Log.d(TAG, "👤 📤 Uploading avatar to Transport Server...")
uploadTag = TransportManager.uploadFile(avatarAttachmentId, encryptedAvatarBlob)
Log.d(TAG, "👤 📤 Upload complete, tag: $uploadTag")
}
// Preview содержит tag::blurhash (как в desktop)
val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$avatarBlurhash" else avatarBlurhash
Log.d(TAG, "👤 Preview with tag: ${previewWithTag.take(50)}...")
val avatarAttachment = MessageAttachment(
id = avatarAttachmentId,
blob = "", // 🔥 ПУСТОЙ blob - файл на Transport Server!
type = AttachmentType.AVATAR,
preview = previewWithTag
)
// 3. Отправляем пакет (с ПУСТЫМ blob!)
val packet = PacketMessage().apply {
fromPublicKey = sender
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
attachments = listOf(avatarAttachment)
}
if (!isSavedMessages) {
ProtocolManager.send(packet)
}
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
// 💾 Сохраняем аватар в файл локально (как IMAGE - с приватным ключом)
AttachmentFileManager.saveAttachment(
context = getApplication(),
blob = avatarBlob,
attachmentId = avatarAttachmentId,
publicKey = sender,
privateKey = userPrivateKey
)
// 4. 💾 Сохраняем в БД (БЕЗ blob - он в файле)
val attachmentJson = JSONObject().apply {
put("id", avatarAttachmentId)
put("type", AttachmentType.AVATAR.value)
put("preview", previewWithTag) // tag::blurhash
put("blob", "") // Пустой blob - не сохраняем в БД!
}
messageDao.insertMessage(
MessageEntity(
account = sender,
fromPublicKey = sender,
toPublicKey = recipient,
content = encryptedContent,
timestamp = timestamp,
chachaKey = encryptedKey,
read = 1,
fromMe = 1,
delivered = DeliveryStatus.DELIVERED.value,
messageId = messageId,
plainMessage = "",
attachments = JSONArray().apply { put(attachmentJson) }.toString(),
replyToMessageId = null,
dialogKey = if (sender < recipient) "${sender}_${recipient}" else "${recipient}_${sender}"
)
)
saveDialog("Photo", timestamp)
Log.d(TAG, "👤 ✅ Avatar message sent successfully!")
} catch (e: Exception) {
Log.e(TAG, "👤 ❌ Failed to send avatar message", e)
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT)
android.widget.Toast.makeText(
getApplication(),
"Failed to send avatar: ${e.message}",
android.widget.Toast.LENGTH_SHORT
).show()
}
} finally {
isSending = false
}
}
}
/**
* Сохранить диалог в базу данных
* 🔥 Используем updateDialogFromMessages для пересчета счетчиков из messages
@@ -2130,6 +2347,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
/**
* Вспомогательная функция для конвертации base64 в Bitmap
*/
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) {
Log.e(TAG, "Failed to decode base64 to bitmap: ${e.message}")
null
}
}
override fun onCleared() {
super.onCleared()
lastReadMessageTimestamp = 0L

View File

@@ -1079,14 +1079,11 @@ fun AvatarAttachment(
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
downloadStatus = DownloadStatus.DECRYPTING
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
// Сначала расшифровываем его
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
val decryptKeyString = String(decryptedKeyAndNonce, Charsets.UTF_8)
val decrypted = MessageCrypto.decryptAttachmentBlobWithPassword(
// 🔥 КРИТИЧНО: Аватар зашифрован с AVATAR_PASSWORD (как на desktop)
// НЕ используем ChaCha ключ сообщения!
val decrypted = CryptoManager.decryptWithPassword(
encryptedContent,
decryptKeyString
AvatarFileManager.getAvatarPassword()
)
if (decrypted != null) {

View File

@@ -72,6 +72,8 @@ fun MediaPickerBottomSheet(
onMediaSelected: (List<MediaItem>) -> Unit,
onOpenCamera: () -> Unit = {},
onOpenFilePicker: () -> Unit = {},
onAvatarClick: () -> Unit = {},
currentUserPublicKey: String = "",
maxSelection: Int = 10
) {
val context = LocalContext.current
@@ -179,7 +181,7 @@ fun MediaPickerBottomSheet(
textColor = textColor
)
// Quick action buttons row (Camera, Gallery, File, Location, etc.)
// Quick action buttons row (Camera, Gallery, File, Avatar, etc.)
QuickActionsRow(
isDarkTheme = isDarkTheme,
onCameraClick = {
@@ -189,6 +191,10 @@ fun MediaPickerBottomSheet(
onFileClick = {
onDismiss()
onOpenFilePicker()
},
onAvatarClick = {
onDismiss()
onAvatarClick()
}
)
@@ -374,7 +380,8 @@ private fun MediaPickerHeader(
private fun QuickActionsRow(
isDarkTheme: Boolean,
onCameraClick: () -> Unit,
onFileClick: () -> Unit
onFileClick: () -> Unit,
onAvatarClick: () -> Unit
) {
val buttonColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
val iconColor = if (isDarkTheme) Color.White else Color.Black
@@ -395,6 +402,15 @@ private fun QuickActionsRow(
onClick = onCameraClick
)
// Avatar button
QuickActionButton(
icon = TablerIcons.User,
label = "Avatar",
backgroundColor = buttonColor,
iconColor = iconColor,
onClick = onAvatarClick
)
// File button
QuickActionButton(
icon = TablerIcons.File,

View File

@@ -23,6 +23,11 @@ object AvatarFileManager {
private const val MAX_IMAGE_SIZE = 2048 // Максимальный размер изображения в пикселях
private const val JPEG_QUALITY = 85 // Качество JPEG сжатия
/**
* Получить пароль для шифрования аватаров (для совместимости с desktop)
*/
fun getAvatarPassword(): String = AVATAR_PASSWORD
/**
* Сохранить аватар в файловую систему
* @param context Android context