From 4228f37cf50fe78cc59642fe69de25bb3385f15d Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 28 Oct 2026 01:58:59 +0100 Subject: [PATCH] Recognize image contents and preserve image dimensions in Android conversations --- .../java/com/utun/chat/data/ChatRepository.kt | 2 + .../main/java/com/utun/chat/data/PhotoUtil.kt | 25 +++++++++ .../com/utun/chat/viewmodel/ChatViewModel.kt | 55 +++++++++++++------ 3 files changed, 66 insertions(+), 16 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index dfc03ca5..54e21d69 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -594,6 +594,8 @@ class ChatRepository { videoDurationMs = obj.optInt("duration_ms"), videoWidth = obj.optInt("width"), videoHeight = obj.optInt("height"), + imageWidth = obj.optInt("width"), + imageHeight = obj.optInt("height"), downloadState = if (!file) "" else if (path.isNotEmpty()) "downloaded" else if (status == "expired") "error" else "downloading", deliveryStatusRes = delivery )) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/PhotoUtil.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/PhotoUtil.kt index 2a3c7f35..320db101 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/PhotoUtil.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/PhotoUtil.kt @@ -11,6 +11,31 @@ import android.media.ExifInterface * Отсутствующий/нечитаемый EXIF трактуется как обычная ориентация; допуск источника проверяет вызывающий код. */ object PhotoUtil { + data class Metadata(val width: Int, val height: Int, val mime: String) + + /** Заголовок изображения, размеры для отображения и фактический формат; пиксели не выделяются. */ + fun metadata(path: String): Metadata { + val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, opts) + require(opts.outWidth in 1..16384 && opts.outHeight in 1..16384) { "Invalid image dimensions ${opts.outWidth}x${opts.outHeight}" } + val mime = opts.outMimeType.orEmpty() + require(mime in setOf("image/jpeg", "image/png", "image/gif", "image/bmp", "image/webp")) { "Unsupported image format: $mime" } + return if (isRotated90(orientation(path))) Metadata(opts.outHeight, opts.outWidth, mime) + else Metadata(opts.outWidth, opts.outHeight, mime) + } + + /** Ограниченное декодирование для превью/просмотрщика, с EXIF и сохранением пропорций. */ + fun decodeFitted(path: String, maxDimension: Int): Bitmap? { + require(maxDimension > 0) + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) { + LogManager.addLog("ERROR", "Image", "Cannot read image dimensions path=$path"); return null + } + var sample = 1 + while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxDimension) sample *= 2 + return decodeOriented(path, sample) + } private fun orientation(path: String): Int = try { ExifInterface(path).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 3e6dcd4d..8834e1af 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -1620,6 +1620,9 @@ class ChatViewModel : ViewModel() { fun sendAttachment(filePath: String, target: ConversationTarget, name: String = File(filePath).name, reply: ChannelReply? = replyFor(target)): Deferred = viewModelScope.async { + val ext = filePath.substringAfterLast('.', "").lowercase() + if (ext in IMAGE_EXTENSIONS && (target is ConversationTarget.Channel || ext in setOf("jpg", "jpeg", "png"))) + return@async sendPhotoMessage(filePath, target, reply, clearReply = false).await() if (target is ConversationTarget.Pm) { val current = dmTargetCurrent(target) val accepted = current && NativeLib.dmSendFile(target.id, filePath, name, target.generation) @@ -1632,17 +1635,14 @@ class ChatViewModel : ViewModel() { } return@async accepted } - val chId = target.id - val ext = filePath.substringAfterLast('.', "").lowercase() - val dims = if (ext in IMAGE_EXTENSIONS) withContext(Dispatchers.IO) { - val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeFile(filePath, opts) - opts.outWidth to opts.outHeight - } else 0 to 0 - submitChannel(chId, reply) { request -> - if (dims.first > 0 && dims.second > 0) - NativeLib.photoSend(chId, filePath, dims.first, dims.second, reply?.message?.messageId.orEmpty(), request) - else NativeLib.attachmentSend(chId, filePath, reply?.message?.messageId.orEmpty(), request) + try { + submitChannel(target.id, reply) { request -> + NativeLib.attachmentSend(target.id, filePath, reply?.message?.messageId.orEmpty(), request) + } + } finally { + withContext(kotlinx.coroutines.NonCancellable + Dispatchers.IO) { + if (!File(filePath).delete() && File(filePath).exists()) LogManager.addLog("ERROR", "Attachment", "Cache cleanup failed: $filePath") + } } } @@ -1690,12 +1690,35 @@ class ChatViewModel : ViewModel() { saved } - fun sendPhotoMessage(filePath: String, width: Int, height: Int, channel: String, - reply: ChannelReply?): Deferred = viewModelScope.async { - val saved = submitChannel(channel, reply) { - NativeLib.photoSend(channel, filePath, width, height, reply?.message?.messageId.orEmpty(), it) + /** Снимок камеры и выбранный JPG/PNG имеют один путь и неизменного адресата. */ + fun sendPhotoMessage(filePath: String, target: ConversationTarget, reply: ChannelReply?, + clearReply: Boolean = true): Deferred = viewModelScope.async { + var accepted = false + var saved = false + var legacy = false + try { + val meta = withContext(Dispatchers.IO) { PhotoUtil.metadata(filePath) } + legacy = meta.mime !in setOf("image/jpeg", "image/png") + val queue: (Long) -> Boolean = { request -> + if (target is ConversationTarget.Pm && !dmTargetCurrent(target)) { + LogManager.addLog("INFO", "Image", "Discarded image for deleted PM target=${target.nativeId}"); false + } else NativeLib.photoSend(target.nativeId, filePath, meta.width, meta.height, meta.mime, + reply?.message?.messageId.orEmpty(), request, (target as? ConversationTarget.Pm)?.generation ?: 0).also { accepted = it } + } + saved = if (target is ConversationTarget.Channel) submitChannel(target.id, reply, queue = queue) else queue(0) + if (!saved) _messageSendFailed.value = true + if (saved && clearReply) finishReply(reply) + LogManager.addLog("INFO", "Image", "Image submission target=${target.nativeId} mime=${meta.mime} " + + "dimensions=${meta.width}x${meta.height} accepted=$accepted saved=$saved") + } catch (e: kotlinx.coroutines.CancellationException) { throw e } + catch (e: Exception) { + LogManager.addLog("ERROR", "Image", "Image submission failed target=${target.nativeId}: ${e.message}") + _messageSendFailed.value = true + } finally { + if (!accepted || legacy) withContext(kotlinx.coroutines.NonCancellable + Dispatchers.IO) { + if (!File(filePath).delete() && File(filePath).exists()) LogManager.addLog("ERROR", "Image", "Cache cleanup failed: $filePath") + } } - if (saved) finishReply(reply) saved }