From 98ccb9078dbf67a06bc0a3fce48c9dc96edb0be5 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 26 Jan 2026 20:45:46 +0500 Subject: [PATCH] feat: Implement avatar sending functionality and update MediaPicker with avatar selection option --- .../messenger/ui/chats/ChatDetailScreen.kt | 6 + .../messenger/ui/chats/ChatViewModel.kt | 239 +++++++++++++++++- .../chats/components/AttachmentComponents.kt | 11 +- .../components/MediaPickerBottomSheet.kt | 20 +- .../messenger/utils/AvatarFileManager.kt | 5 + 5 files changed, 270 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 1b77bc2..2079a8e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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() } ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index b272bbf..8627dd3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index f4519a9..f4c65ed 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -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) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt index d471ce1..2e0dd7d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt @@ -72,6 +72,8 @@ fun MediaPickerBottomSheet( onMediaSelected: (List) -> 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, diff --git a/app/src/main/java/com/rosetta/messenger/utils/AvatarFileManager.kt b/app/src/main/java/com/rosetta/messenger/utils/AvatarFileManager.kt index 1dbe458..3e38553 100644 --- a/app/src/main/java/com/rosetta/messenger/utils/AvatarFileManager.kt +++ b/app/src/main/java/com/rosetta/messenger/utils/AvatarFileManager.kt @@ -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