From f27419165d52a639c72bd0fb778706325a097913 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 19:38:40 +0200 Subject: [PATCH] Preserve Android avatar originals and encode selected WebP crops --- .../java/com/utun/chat/data/AvatarAssets.kt | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarAssets.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarAssets.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarAssets.kt new file mode 100644 index 00000000..4f3086e8 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarAssets.kt @@ -0,0 +1,114 @@ +package com.utun.chat.data + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import org.json.JSONObject +import java.io.File +import java.io.IOException +import java.security.MessageDigest +import java.util.UUID +import kotlin.math.roundToInt + +/** Координаты нормализованы относительно полного оригинала после EXIF-ориентации. */ +data class AvatarCrop(val x: Double, val y: Double, val w: Double, val h: Double) +data class AvatarRecord(val original: String, val preview: String, val originalBytes: Long, val previewBytes: Long, + val width: Int, val height: Int, val crop: AvatarCrop) + +/** Неизменяемые объекты: имя — SHA256 точных байтов; все методы вызываются в IO worker. */ +object AvatarAssets { + fun digest(bytes: ByteArray) = AvatarProtocol.hash(MessageDigest.getInstance("SHA-256").digest(bytes)) + fun read(file: File, name: String, limit: Int): ByteArray { + require(AvatarProtocol.validObject(name) && file.length() in 1..limit) { "invalid object file=$name size=${file.length()}" } + val bytes = file.inputStream().use { it.readBytes() } + require(bytes.size <= limit && digest(bytes) == name.take(64)) { "avatar object hash mismatch file=$name" } + return bytes + } + fun store(root: File, name: String, bytes: ByteArray) { + require(AvatarProtocol.validObject(name) && digest(bytes) == name.take(64)) { "invalid cache object name=$name" } + if (!root.isDirectory && !root.mkdirs()) throw IOException("cannot create avatar cache") + val temp = File(root, ".part-${UUID.randomUUID()}") + try { temp.writeBytes(bytes); if (!temp.renameTo(File(root, name))) throw IOException("cannot publish object=$name") } + finally { if (temp.exists() && !temp.delete()) LogManager.addLog("WARN", "Avatar", "Cannot remove cache temp=$temp") } + } + fun copySource(context: Context, uri: Uri): File { + val file = File.createTempFile("avatar-editor-", ".image", context.cacheDir) + try { + context.contentResolver.openInputStream(uri)?.use { input -> file.outputStream().use { output -> + val chunk = ByteArray(8192); var size = 0 + while (true) { + val count = input.read(chunk); if (count < 0) break + size += count; require(size <= AvatarProtocol.MAX_ORIGINAL) { "original exceeds 32 MiB" } + output.write(chunk, 0, count) + } + } } ?: throw IOException("cannot open selected image") + return file + } catch (e: Exception) { + if (!file.delete()) LogManager.addLog("WARN", "Avatar", "Cannot remove rejected source=$file") + throw e + } + } + fun record(root: File, name: String): AvatarRecord = parse(read(File(root, name), name, AvatarProtocol.MAX_MANIFEST)) + fun parse(bytes: ByteArray): AvatarRecord { + val o = JSONObject(String(bytes, Charsets.UTF_8)) + val r = AvatarRecord(o.getString("original"), o.getString("preview"), o.getLong("original_bytes"), o.getLong("preview_bytes"), + o.getInt("width"), o.getInt("height"), AvatarCrop(o.getDouble("x"), o.getDouble("y"), o.getDouble("w"), o.getDouble("h"))) + val c = r.crop + require(o.getInt("version") == 1 && AvatarProtocol.validObject(r.original) && !r.original.endsWith(".json") && + AvatarProtocol.validObject(r.preview) && r.preview.endsWith(".webp") && r.originalBytes in 1..AvatarProtocol.MAX_ORIGINAL && + r.previewBytes in 1..AvatarProtocol.MAX_USER && r.width > 0 && r.height > 0 && r.width.toLong()*r.height <= 16L*1024*1024 && + listOf(c.x,c.y,c.w,c.h).all { it.isFinite() } && c.x >= 0 && c.y >= 0 && c.w > 0 && c.h > 0 && + c.x+c.w <= 1.00000001 && c.y+c.h <= 1.00000001 && kotlin.math.abs(c.w*r.width-c.h*r.height) <= 2) { "invalid avatar manifest" } + return r + } + fun image(root: File, record: AvatarRecord, original: Boolean): Bitmap { + val name = if (original) record.original else record.preview + val bytes = read(File(root, name), name, if (original) AvatarProtocol.MAX_ORIGINAL else AvatarProtocol.MAX_USER) + require(bytes.size.toLong() == if (original) record.originalBytes else record.previewBytes) { "manifest object size mismatch" } + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + require(bounds.outWidth > 0 && bounds.outHeight > 0 && bounds.outWidth.toLong()*bounds.outHeight <= 16L*1024*1024 && + bounds.outMimeType in listOf("image/jpeg", "image/png", "image/webp")) { "invalid avatar image" } + if (!original) require(bounds.outWidth == 256 && bounds.outHeight == 256 && bounds.outMimeType == "image/webp") { "invalid WebP preview" } + var sample = 1; while (maxOf(bounds.outWidth,bounds.outHeight)/sample > 2048) sample *= 2 + return PhotoUtil.decodeOriented(File(root,name).path,sample) ?: throw IOException("avatar decode failed") + } + fun prepare(source: File, root: File, selection: AvatarCrop): Pair { + require(source.length() in 1..AvatarProtocol.MAX_ORIGINAL) { "invalid original size" } + val original = source.readBytes() + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(original,0,original.size,bounds) + val suffix = when (bounds.outMimeType) { "image/jpeg" -> ".jpg"; "image/png" -> ".png"; "image/webp" -> ".webp" + else -> throw IOException("unsupported avatar image") } + val (width,height) = PhotoUtil.orientedDimensions(source.path) + require(width > 0 && height > 0 && width.toLong()*height <= 16L*1024*1024) { "original exceeds 16 megapixels" } + val decoded = PhotoUtil.decodeOriented(source.path,1) ?: throw IOException("cannot decode source") + val x = (selection.x*width).roundToInt(); val y = (selection.y*height).roundToInt() + val side = (selection.w*width).roundToInt() + require(x >= 0 && y >= 0 && side > 0 && x+side <= width && y+side <= height && + kotlin.math.abs(selection.h*height-side) <= 2) { "invalid selected square" } + val crop = Bitmap.createBitmap(decoded,x,y,side,side) + val scaled = Bitmap.createScaledBitmap(crop,256,256,true) + try { + val output = java.io.ByteArrayOutputStream() + val format = if (Build.VERSION.SDK_INT >= 30) Bitmap.CompressFormat.WEBP_LOSSY else Bitmap.CompressFormat.WEBP + require(scaled.compress(format,85,output)) { "WebP encoder failed" } + val preview = output.toByteArray(); require(preview.size <= AvatarProtocol.MAX_USER) { "preview exceeds limit" } + val originalName = digest(original)+suffix; val previewName = digest(preview)+".webp" + val manifest = JSONObject().put("version",1).put("original",originalName).put("preview",previewName) + .put("original_bytes",original.size).put("preview_bytes",preview.size).put("width",width).put("height",height) + .put("x",x.toDouble()/width).put("y",y.toDouble()/height).put("w",side.toDouble()/width).put("h",side.toDouble()/height) + .toString().toByteArray(Charsets.UTF_8) + val name = digest(manifest)+".json" + store(root,originalName,original); store(root,previewName,preview); store(root,name,manifest) + LogManager.addLog("INFO", "Avatar", "Prepared file=$name original=${original.size} preview=${preview.size} crop=$x,$y,$side") + return name to scaled.copy(Bitmap.Config.ARGB_8888,false) + } finally { + if (scaled !== crop && scaled !== decoded) scaled.recycle() + if (crop !== decoded) crop.recycle() + decoded.recycle() + } + } +}