1 changed files with 114 additions and 0 deletions
@ -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<String, Bitmap> { |
||||
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() |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue