Recognize image contents and preserve image dimensions in Android conversations

This commit is contained in:
evgeny committed 2026-10-28 12:08:06 +01:00
1 parent 233a3f78d1
commit 4228f37cf5
3 files changed
+66 -16

No files matched your search

@@ -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
))
@@ -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)
@@ -1620,6 +1620,9 @@ class ChatViewModel : ViewModel() {
fun sendAttachment(filePath: String, target: ConversationTarget, name: String = File(filePath).name,
reply: ChannelReply? = replyFor(target)): Deferred<Boolean> = 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<Boolean> = 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<Boolean> = 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
}