feat: Implement avatar sending functionality and update MediaPicker with avatar selection option
This commit is contained in:
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user