From dd7d7b19cf5abac0c14987c27c0dc3a5d5e1ada2 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 3 Feb 2026 23:40:43 +0500 Subject: [PATCH] fix: implement optimistic UI for image sending and enhance attachment handling --- .../com/rosetta/messenger/network/Packets.kt | 3 +- .../messenger/ui/chats/ChatDetailScreen.kt | 53 +--- .../messenger/ui/chats/ChatViewModel.kt | 229 ++++++++++++++++++ .../chats/components/AttachmentComponents.kt | 99 +++++++- .../ui/chats/components/ImageEditorScreen.kt | 102 +++++--- 5 files changed, 406 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/network/Packets.kt b/app/src/main/java/com/rosetta/messenger/network/Packets.kt index 53abbe4..f91c8e1 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Packets.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Packets.kt @@ -265,7 +265,8 @@ data class MessageAttachment( val type: AttachmentType, val preview: String = "", // Метаданные: "UUID::metadata" или "filesize::filename" val width: Int = 0, - val height: Int = 0 + val height: Int = 0, + val localUri: String = "" // 🚀 Локальный URI для мгновенного отображения (optimistic UI) ) /** 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 e1e9a59..b65fc80 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 @@ -2151,27 +2151,12 @@ fun ChatDetailScreen( pendingCameraPhotoUri = null }, onSave = { editedUri -> - // Fallback если onSaveWithCaption не сработал - pendingCameraPhotoUri = null - scope.launch { - val base64 = MediaUtils.uriToBase64Image(context, editedUri) - val blurhash = MediaUtils.generateBlurhash(context, editedUri) - val (width, height) = MediaUtils.getImageDimensions(context, editedUri) - if (base64 != null) { - viewModel.sendImageMessage(base64, blurhash, "", width, height) - } - } + // 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ + viewModel.sendImageFromUri(editedUri, "") }, onSaveWithCaption = { editedUri, caption -> - pendingCameraPhotoUri = null - scope.launch { - val base64 = MediaUtils.uriToBase64Image(context, editedUri) - val blurhash = MediaUtils.generateBlurhash(context, editedUri) - val (width, height) = MediaUtils.getImageDimensions(context, editedUri) - if (base64 != null) { - viewModel.sendImageMessage(base64, blurhash, caption, width, height) - } - } + // 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ + viewModel.sendImageFromUri(editedUri, caption) }, isDarkTheme = isDarkTheme, showCaptionInput = true, @@ -2187,33 +2172,9 @@ fun ChatDetailScreen( pendingGalleryImages = emptyList() }, onSendAll = { imagesWithCaptions -> - pendingGalleryImages = emptyList() - scope.launch { - // Собираем все изображения с их caption - val imageDataList = mutableListOf() - - for (imageWithCaption in imagesWithCaptions) { - val base64 = MediaUtils.uriToBase64Image(context, imageWithCaption.uri) - val blurhash = MediaUtils.generateBlurhash(context, imageWithCaption.uri) - val (width, height) = MediaUtils.getImageDimensions(context, imageWithCaption.uri) - if (base64 != null) { - imageDataList.add(ChatViewModel.ImageData(base64, blurhash, width, height)) - } - } - - // Если одно фото с caption - отправляем как обычное сообщение - if (imageDataList.size == 1 && imagesWithCaptions[0].caption.isNotBlank()) { - viewModel.sendImageMessage( - imageDataList[0].base64, - imageDataList[0].blurhash, - imagesWithCaptions[0].caption, - imageDataList[0].width, - imageDataList[0].height - ) - } else if (imageDataList.isNotEmpty()) { - // Отправляем группой (коллаж) - viewModel.sendImageGroup(imageDataList) - } + // 🚀 Мгновенный optimistic UI для каждого фото + for (imageWithCaption in imagesWithCaptions) { + viewModel.sendImageFromUri(imageWithCaption.uri, imageWithCaption.caption) } }, isDarkTheme = isDarkTheme, 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 6e4e7ea..9dfa532 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 @@ -1467,6 +1467,235 @@ val newList = messages + optimisticMessages } } + /** + * 📸🚀 Отправка изображения по URI с МГНОВЕННЫМ optimistic UI + * Фото появляется в чате СРАЗУ, конвертация и отправка происходят в фоне + * + * @param imageUri URI изображения + * @param caption Подпись к изображению + */ + fun sendImageFromUri(imageUri: android.net.Uri, caption: String = "") { + val recipient = opponentKey + val sender = myPublicKey + val privateKey = myPrivateKey + val context = getApplication() + + if (recipient == null || sender == null || privateKey == null) { + return + } + + val messageId = UUID.randomUUID().toString().replace("-", "").take(32) + val timestamp = System.currentTimeMillis() + val text = caption.trim() + + // 1. 🚀 МГНОВЕННО показываем optimistic сообщение с localUri + // Используем URI напрямую для отображения (без конвертации в base64) + val optimisticMessage = ChatMessage( + id = messageId, + text = text, + isOutgoing = true, + timestamp = Date(timestamp), + status = MessageStatus.SENDING, + attachments = listOf( + MessageAttachment( + id = "img_$timestamp", + blob = "", // Пока пустой, обновим после конвертации + type = AttachmentType.IMAGE, + preview = "", // Пока пустой, обновим после генерации + width = 0, + height = 0, + localUri = imageUri.toString() // 🔥 Используем localUri для мгновенного показа + ) + ) + ) + addMessageSafely(optimisticMessage) + _inputText.value = "" + + // 2. 🔄 В ФОНЕ: конвертируем, генерируем blurhash и отправляем + viewModelScope.launch(Dispatchers.IO) { + try { + // Получаем размеры изображения + val (width, height) = com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri) + + // Конвертируем в base64 + val imageBase64 = com.rosetta.messenger.utils.MediaUtils.uriToBase64Image(context, imageUri) + if (imageBase64 == null) { + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.ERROR) + } + return@launch + } + + // Генерируем blurhash + val blurhash = com.rosetta.messenger.utils.MediaUtils.generateBlurhash(context, imageUri) + + // 3. 🔄 Обновляем optimistic сообщение с реальными данными + withContext(Dispatchers.Main) { + updateOptimisticImageMessage(messageId, imageBase64, blurhash, width, height) + } + + // 4. 📤 Отправляем (шифрование + загрузка на сервер) + sendImageMessageInternal( + messageId = messageId, + imageBase64 = imageBase64, + blurhash = blurhash, + caption = text, + width = width, + height = height, + timestamp = timestamp, + recipient = recipient, + sender = sender, + privateKey = privateKey + ) + + } catch (e: Exception) { + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.ERROR) + } + } + } + } + + /** + * 🔄 Обновляет optimistic сообщение с реальными данными изображения + */ + private fun updateOptimisticImageMessage(messageId: String, base64: String, blurhash: String, width: Int, height: Int) { + val currentMessages = _messages.value.toMutableList() + val index = currentMessages.indexOfFirst { it.id == messageId } + if (index != -1) { + val message = currentMessages[index] + val updatedAttachments = message.attachments.map { att -> + if (att.type == AttachmentType.IMAGE) { + att.copy( + preview = blurhash, + blob = base64, + width = width, + height = height + ) + } else att + } + currentMessages[index] = message.copy(attachments = updatedAttachments) + _messages.value = currentMessages + } + } + + /** + * 📤 Внутренняя функция отправки изображения (уже с готовым base64) + */ + private suspend fun sendImageMessageInternal( + messageId: String, + imageBase64: String, + blurhash: String, + caption: String, + width: Int, + height: Int, + timestamp: Long, + recipient: String, + sender: String, + privateKey: String + ) { + try { + val context = getApplication() + + // Шифрование текста + val encryptResult = MessageCrypto.encryptForSending(caption, recipient) + val encryptedContent = encryptResult.ciphertext + val encryptedKey = encryptResult.encryptedKey + val plainKeyAndNonce = encryptResult.plainKeyAndNonce + + val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) + + // 🚀 Шифруем изображение с ChaCha ключом для Transport Server + val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce) + + val attachmentId = "img_$timestamp" + + // 📤 Загружаем на Transport Server + val isSavedMessages = (sender == recipient) + var uploadTag = "" + + if (!isSavedMessages) { + uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob) + } + + // Preview содержит tag::blurhash + val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash + + val imageAttachment = MessageAttachment( + id = attachmentId, + blob = "", + type = AttachmentType.IMAGE, + preview = previewWithTag, + width = width, + height = height + ) + + val packet = PacketMessage().apply { + fromPublicKey = sender + toPublicKey = recipient + content = encryptedContent + chachaKey = encryptedKey + this.timestamp = timestamp + this.privateKey = privateKeyHash + this.messageId = messageId + attachments = listOf(imageAttachment) + } + + // Отправляем пакет + if (!isSavedMessages) { + ProtocolManager.send(packet) + } + + // 💾 Сохраняем изображение в файл локально + AttachmentFileManager.saveAttachment( + context = context, + blob = imageBase64, + attachmentId = attachmentId, + publicKey = sender, + privateKey = privateKey + ) + + // Сохраняем в БД + val attachmentsJson = JSONArray().apply { + put(JSONObject().apply { + put("id", attachmentId) + put("type", AttachmentType.IMAGE.value) + put("preview", previewWithTag) + put("blob", "") + put("width", width) + put("height", height) + }) + }.toString() + + saveMessageToDatabase( + messageId = messageId, + text = caption, + encryptedContent = encryptedContent, + encryptedKey = encryptedKey, + timestamp = timestamp, + isFromMe = true, + delivered = if (isSavedMessages) 2 else 0, + attachmentsJson = attachmentsJson + ) + + // Обновляем статус на SENT + if (!isSavedMessages) { + updateMessageStatusInDb(messageId, 2) + } + + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.SENT) + } + + saveDialog(if (caption.isNotEmpty()) caption else "photo", timestamp) + + } catch (e: Exception) { + withContext(Dispatchers.Main) { + updateMessageStatus(messageId, MessageStatus.ERROR) + } + } + } + /** * 📸 Отправка сообщения с изображением * @param imageBase64 Base64 изображения 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 33e646b..e13cc97 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 @@ -2,6 +2,7 @@ package com.rosetta.messenger.ui.chats.components import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Matrix import android.util.Base64 import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateFloatAsState @@ -33,6 +34,7 @@ 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 androidx.exifinterface.media.ExifInterface import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.MessageAttachment @@ -538,11 +540,15 @@ fun ImageAttachment( ) // Определяем начальный статус и декодируем blurhash (как в Desktop calcDownloadStatus) - LaunchedEffect(attachment.id) { + LaunchedEffect(attachment.id, attachment.localUri) { // Определяем статус (логика из Desktop useAttachment.ts) withContext(Dispatchers.IO) { downloadStatus = when { + // 🚀 0. Если есть localUri - это optimistic UI, показываем сразу + attachment.localUri.isNotEmpty() -> { + DownloadStatus.DOWNLOADED + } // 1. Если blob уже есть в памяти → DOWNLOADED attachment.blob.isNotEmpty() -> { DownloadStatus.DOWNLOADED @@ -587,11 +593,67 @@ fun ImageAttachment( // Загружаем изображение если статус DOWNLOADED if (downloadStatus == DownloadStatus.DOWNLOADED) { withContext(Dispatchers.IO) { - // 1. Сначала пробуем blob из памяти - if (attachment.blob.isNotEmpty()) { + // 🚀 0. Если есть localUri - загружаем напрямую из URI (optimistic UI) + if (attachment.localUri.isNotEmpty()) { + try { + val uri = android.net.Uri.parse(attachment.localUri) + + // 📐 Читаем EXIF ориентацию + val orientation = try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + val exif = ExifInterface(inputStream) + exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + } ?: ExifInterface.ORIENTATION_NORMAL + } catch (e: Exception) { + ExifInterface.ORIENTATION_NORMAL + } + + // Загружаем bitmap + val inputStream = context.contentResolver.openInputStream(uri) + var bitmap = BitmapFactory.decodeStream(inputStream) + inputStream?.close() + + // 🔄 Применяем EXIF ориентацию + if (bitmap != null && orientation != ExifInterface.ORIENTATION_NORMAL) { + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f) + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.postRotate(90f) + matrix.preScale(-1f, 1f) + } + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.postRotate(270f) + matrix.preScale(-1f, 1f) + } + } + val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + if (rotated != bitmap) { + bitmap.recycle() + bitmap = rotated + } + } + + imageBitmap = bitmap + } catch (e: Exception) { + // Fallback to other methods + } + } + + // 1. Если нет bitmap, пробуем blob из памяти + if (imageBitmap == null && attachment.blob.isNotEmpty()) { imageBitmap = base64ToBitmap(attachment.blob) - } else { - // 2. Читаем из файловой системы (как в Desktop getBlob) + } + + // 2. Если всё ещё нет, читаем из файловой системы + if (imageBitmap == null) { val localBlob = AttachmentFileManager.readAttachment( context, @@ -601,7 +663,8 @@ fun ImageAttachment( ) if (localBlob != null) { imageBitmap = base64ToBitmap(localBlob) - } else { + } else if (attachment.localUri.isEmpty()) { + // Только если нет localUri - помечаем как NOT_DOWNLOADED downloadStatus = DownloadStatus.NOT_DOWNLOADED } } @@ -902,6 +965,30 @@ fun ImageAttachment( } } + // ✈️ Telegram-style: Loader при отправке фото (самолётик/кружок) + // Показываем когда сообщение отправляется И это исходящее сообщение + if (isOutgoing && messageStatus == MessageStatus.SENDING && downloadStatus == DownloadStatus.DOWNLOADED) { + Box( + modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)), + contentAlignment = Alignment.Center + ) { + // Круглый индикатор отправки + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + color = Color.White, + strokeWidth = 2.5.dp + ) + } + } + } + // Оверлей для статуса скачивания if (downloadStatus != DownloadStatus.DOWNLOADED) { Box( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt index b434156..574fd56 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt @@ -603,19 +603,27 @@ fun ImageEditorScreen( onToggleEmojiPicker = { toggleEmojiPicker() }, onEditTextViewCreated = { editTextView = it }, onSend = { + // 🚀 Сохраняем caption для использования после анимации + val captionToSend = caption + val uriToSend = currentImageUri + + // ✈️ Сразу запускаем fade-out анимацию (как в Telegram) + // Фото появится в чате через optimistic UI scope.launch { - isSaving = true - saveEditedImage(context, photoEditor, photoEditorView, currentImageUri) { savedUri -> - isSaving = false + saveEditedImage(context, photoEditor, photoEditorView, uriToSend) { savedUri -> if (savedUri != null) { + // Вызываем callback (он запустит sendImageMessage с optimistic UI) if (onSaveWithCaption != null) { - onSaveWithCaption(savedUri, caption) + onSaveWithCaption(savedUri, captionToSend) } else { onSave(savedUri) } } } } + + // 🎬 Плавно закрываем экран (fade-out) + animatedDismiss() } ) @@ -1478,6 +1486,39 @@ fun MultiImageEditorScreen( val scope = rememberCoroutineScope() val view = LocalView.current + // ═══════════════════════════════════════════════════════════════ + // 🎬 TELEGRAM-STYLE FADE ANIMATION + // ═══════════════════════════════════════════════════════════════ + var isClosing by remember { mutableStateOf(false) } + val animationProgress = remember { Animatable(0f) } + + // Запуск enter анимации + LaunchedEffect(Unit) { + animationProgress.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = 200, + easing = FastOutSlowInEasing + ) + ) + } + + // Функция для плавного закрытия + fun animatedDismiss() { + if (isClosing) return + isClosing = true + scope.launch { + animationProgress.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = 200, + easing = FastOutSlowInEasing + ) + ) + onDismiss() + } + } + // 🎨 Черный статус бар и навигационный бар для редактора val activity = context as? Activity val window = activity?.window @@ -1554,12 +1595,20 @@ fun MultiImageEditorScreen( } } - BackHandler { onDismiss() } + BackHandler { animatedDismiss() } + // 🎬 Анимированный контейнер с fade эффектом + val progress = animationProgress.value + Box( modifier = Modifier .fillMaxSize() - .background(Color.Black) + .background(Color.Black.copy(alpha = progress)) + .graphicsLayer { + alpha = progress + scaleX = 0.95f + 0.05f * progress + scaleY = 0.95f + 0.05f * progress + } ) { // Pager HorizontalPager( @@ -1646,7 +1695,7 @@ fun MultiImageEditorScreen( .padding(horizontal = 4.dp, vertical = 8.dp) ) { IconButton( - onClick = onDismiss, + onClick = { animatedDismiss() }, modifier = Modifier.align(Alignment.CenterStart) ) { Icon( @@ -1829,15 +1878,18 @@ fun MultiImageEditorScreen( .size(48.dp) .clip(CircleShape) .background(PrimaryBlue) - .clickable(enabled = !isSaving) { - isSaving = true + .clickable(enabled = !isSaving && !isClosing) { + // 🚀 Сохраняем копию данных перед анимацией + val imagesToSend = imagesWithCaptions.toList() + + // 🎬 Запускаем fade-out анимацию сразу scope.launch { val savedImages = mutableListOf() - for (i in imagesWithCaptions.indices) { + for (i in imagesToSend.indices) { val editor = photoEditors[i] val editorView = photoEditorViews[i] - val originalImage = imagesWithCaptions[i] + val originalImage = imagesToSend[i] if (editor != null) { val savedUri = saveEditedImageSync(context, editor, editorView, originalImage.uri) @@ -1851,27 +1903,23 @@ fun MultiImageEditorScreen( } } + // Вызываем callback (он запустит sendImageGroup с optimistic UI) onSendAll(savedImages) } + + // ✈️ Плавно закрываем экран (fade-out) + animatedDismiss() }, contentAlignment = Alignment.Center ) { - if (isSaving) { - CircularProgressIndicator( - modifier = Modifier.size(22.dp), - color = Color.White, - strokeWidth = 2.dp - ) - } else { - Icon( - TablerIcons.Send, - contentDescription = "Send", - tint = Color.White, - modifier = Modifier - .size(22.dp) - .offset(x = 1.dp) - ) - } + Icon( + TablerIcons.Send, + contentDescription = "Send", + tint = Color.White, + modifier = Modifier + .size(22.dp) + .offset(x = 1.dp) + ) } }