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,
|
isVisible = showMediaPicker,
|
||||||
onDismiss = { showMediaPicker = false },
|
onDismiss = { showMediaPicker = false },
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
currentUserPublicKey = currentUserPublicKey,
|
||||||
onMediaSelected = { selectedMedia ->
|
onMediaSelected = { selectedMedia ->
|
||||||
// 📸 Отправляем выбранные изображения как коллаж (группу)
|
// 📸 Отправляем выбранные изображения как коллаж (группу)
|
||||||
android.util.Log.d("ChatDetailScreen", "📸 Sending ${selectedMedia.size} media items as group")
|
android.util.Log.d("ChatDetailScreen", "📸 Sending ${selectedMedia.size} media items as group")
|
||||||
@@ -2015,6 +2016,11 @@ fun ChatDetailScreen(
|
|||||||
onOpenFilePicker = {
|
onOpenFilePicker = {
|
||||||
// 📄 Открываем файловый пикер
|
// 📄 Открываем файловый пикер
|
||||||
filePickerLauncher.launch("*/*")
|
filePickerLauncher.launch("*/*")
|
||||||
|
},
|
||||||
|
onAvatarClick = {
|
||||||
|
// 👤 Отправляем свой аватар (как в desktop)
|
||||||
|
android.util.Log.d("ChatDetailScreen", "👤 Sending avatar...")
|
||||||
|
viewModel.sendAvatarMessage()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.rosetta.messenger.ui.chats
|
package com.rosetta.messenger.ui.chats
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@@ -797,8 +800,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val attachmentId = attachment.optString("id", "")
|
val attachmentId = attachment.optString("id", "")
|
||||||
val attachmentType = AttachmentType.fromInt(type)
|
val attachmentType = AttachmentType.fromInt(type)
|
||||||
|
|
||||||
// 💾 Для IMAGE - пробуем загрузить blob из файла если пустой
|
// 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой
|
||||||
if (attachmentType == AttachmentType.IMAGE && blob.isEmpty() && attachmentId.isNotEmpty()) {
|
if ((attachmentType == AttachmentType.IMAGE || attachmentType == AttachmentType.AVATAR)
|
||||||
|
&& blob.isEmpty() && attachmentId.isNotEmpty()) {
|
||||||
val fileBlob = AttachmentFileManager.readAttachment(
|
val fileBlob = AttachmentFileManager.readAttachment(
|
||||||
context = getApplication(),
|
context = getApplication(),
|
||||||
attachmentId = attachmentId,
|
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
|
* 🔥 Используем 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() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
lastReadMessageTimestamp = 0L
|
lastReadMessageTimestamp = 0L
|
||||||
|
|||||||
@@ -1079,14 +1079,11 @@ fun AvatarAttachment(
|
|||||||
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
|
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
|
||||||
downloadStatus = DownloadStatus.DECRYPTING
|
downloadStatus = DownloadStatus.DECRYPTING
|
||||||
|
|
||||||
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
|
// 🔥 КРИТИЧНО: Аватар зашифрован с AVATAR_PASSWORD (как на desktop)
|
||||||
// Сначала расшифровываем его
|
// НЕ используем ChaCha ключ сообщения!
|
||||||
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
val decrypted = CryptoManager.decryptWithPassword(
|
||||||
val decryptKeyString = String(decryptedKeyAndNonce, Charsets.UTF_8)
|
|
||||||
|
|
||||||
val decrypted = MessageCrypto.decryptAttachmentBlobWithPassword(
|
|
||||||
encryptedContent,
|
encryptedContent,
|
||||||
decryptKeyString
|
AvatarFileManager.getAvatarPassword()
|
||||||
)
|
)
|
||||||
|
|
||||||
if (decrypted != null) {
|
if (decrypted != null) {
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ fun MediaPickerBottomSheet(
|
|||||||
onMediaSelected: (List<MediaItem>) -> Unit,
|
onMediaSelected: (List<MediaItem>) -> Unit,
|
||||||
onOpenCamera: () -> Unit = {},
|
onOpenCamera: () -> Unit = {},
|
||||||
onOpenFilePicker: () -> Unit = {},
|
onOpenFilePicker: () -> Unit = {},
|
||||||
|
onAvatarClick: () -> Unit = {},
|
||||||
|
currentUserPublicKey: String = "",
|
||||||
maxSelection: Int = 10
|
maxSelection: Int = 10
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -179,7 +181,7 @@ fun MediaPickerBottomSheet(
|
|||||||
textColor = textColor
|
textColor = textColor
|
||||||
)
|
)
|
||||||
|
|
||||||
// Quick action buttons row (Camera, Gallery, File, Location, etc.)
|
// Quick action buttons row (Camera, Gallery, File, Avatar, etc.)
|
||||||
QuickActionsRow(
|
QuickActionsRow(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
onCameraClick = {
|
onCameraClick = {
|
||||||
@@ -189,6 +191,10 @@ fun MediaPickerBottomSheet(
|
|||||||
onFileClick = {
|
onFileClick = {
|
||||||
onDismiss()
|
onDismiss()
|
||||||
onOpenFilePicker()
|
onOpenFilePicker()
|
||||||
|
},
|
||||||
|
onAvatarClick = {
|
||||||
|
onDismiss()
|
||||||
|
onAvatarClick()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -374,7 +380,8 @@ private fun MediaPickerHeader(
|
|||||||
private fun QuickActionsRow(
|
private fun QuickActionsRow(
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
onCameraClick: () -> Unit,
|
onCameraClick: () -> Unit,
|
||||||
onFileClick: () -> Unit
|
onFileClick: () -> Unit,
|
||||||
|
onAvatarClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val buttonColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
val buttonColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
||||||
val iconColor = if (isDarkTheme) Color.White else Color.Black
|
val iconColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
@@ -395,6 +402,15 @@ private fun QuickActionsRow(
|
|||||||
onClick = onCameraClick
|
onClick = onCameraClick
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Avatar button
|
||||||
|
QuickActionButton(
|
||||||
|
icon = TablerIcons.User,
|
||||||
|
label = "Avatar",
|
||||||
|
backgroundColor = buttonColor,
|
||||||
|
iconColor = iconColor,
|
||||||
|
onClick = onAvatarClick
|
||||||
|
)
|
||||||
|
|
||||||
// File button
|
// File button
|
||||||
QuickActionButton(
|
QuickActionButton(
|
||||||
icon = TablerIcons.File,
|
icon = TablerIcons.File,
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ object AvatarFileManager {
|
|||||||
private const val MAX_IMAGE_SIZE = 2048 // Максимальный размер изображения в пикселях
|
private const val MAX_IMAGE_SIZE = 2048 // Максимальный размер изображения в пикселях
|
||||||
private const val JPEG_QUALITY = 85 // Качество JPEG сжатия
|
private const val JPEG_QUALITY = 85 // Качество JPEG сжатия
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить пароль для шифрования аватаров (для совместимости с desktop)
|
||||||
|
*/
|
||||||
|
fun getAvatarPassword(): String = AVATAR_PASSWORD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сохранить аватар в файловую систему
|
* Сохранить аватар в файловую систему
|
||||||
* @param context Android context
|
* @param context Android context
|
||||||
|
|||||||
Reference in New Issue
Block a user