Document Android avatar storage and publication contracts

This commit is contained in:
evgeny committed 2026-10-28 11:36:13 +01:00
1 parent 6f8baf82c7
commit 977992e834
3 files changed
+88 -4

No files matched your search

@@ -12,20 +12,30 @@ import java.security.MessageDigest
import java.util.UUID
import kotlin.math.roundToInt
/** Координаты нормализованы относительно полного оригинала после EXIF-ориентации. */
/** Квадрат в нормализованных координатах полного оригинала после EXIF-ориентации.
* x/y — левый верхний угол, w/h — доли ширины/высоты; квадратность проверяется в пикселях оригинала. */
data class AvatarCrop(val x: Double, val y: Double, val w: Double, val h: Double)
/** Манифест v1: имена объектов с SHA256, точные размеры файлов в байтах, ориентированные размеры и выбранный квадрат. */
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. */
/**
* Локальные объекты пользовательского аватара; I/O, декодирование и подготовку выполнять на IO worker.
* Набор состоит из неизменённого JPEG/PNG/WebP-оригинала, квадратного WebP 256×256 и JSON-манифеста v1.
* Имя каждого объекта — SHA256 его точных байтов плюс расширение; root задаёт каталог текущего аккаунта.
* read/record/image проверяют данные, store публикует через временный файл и rename в том же каталоге.
* Нарушение формата/лимита/хеша бросает исключение: вызывающий код логирует ошибку и выбирает повтор/отказ.
*/
object AvatarAssets {
fun digest(bytes: ByteArray) = AvatarProtocol.hash(MessageDigest.getInstance("SHA-256").digest(bytes))
/** Прочитать file с проверкой лимита байтов и SHA256 из name; имя самого file может быть временным. */
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
}
/** Проверить имя/хеш и сохранить точные 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")
@@ -33,6 +43,8 @@ object AvatarAssets {
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") }
}
/** Скопировать URI в cacheDir, максимум 32 МиБ; успешный результат обязан удалить получатель.
* Изображение здесь не декодируется и не проверяется; при отказе временный файл удаляется. */
fun copySource(context: Context, uri: Uri): File {
val file = File.createTempFile("avatar-editor-", ".image", context.cacheDir)
try {
@@ -50,7 +62,10 @@ object AvatarAssets {
throw e
}
}
/** Прочитать манифест из root с лимитом 4096 байт, проверкой SHA256 и схемы parse. */
fun record(root: File, name: String): AvatarRecord = parse(read(File(root, name), name, AvatarProtocol.MAX_MANIFEST))
/** Проверить JSON v1: имена/лимиты, размеры до 16×1024×1024 пикселей и квадрат crop с допуском 2 пикселя.
* Сами объекты и их соответствие манифесту здесь не читаются. */
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"),
@@ -63,6 +78,9 @@ object AvatarAssets {
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
}
/** Декодировать оригинал либо превью: проверить SHA256, заявленный размер байтов, MIME и предел пикселей.
* Превью обязано быть WebP 256×256. Оригинал уменьшается при декодировании и сохраняет полный кадр без crop.
* Применяется EXIF через PhotoUtil; возвращённый Bitmap принадлежит вызывающему коду. */
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)
@@ -75,6 +93,9 @@ object AvatarAssets {
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")
}
/** Подготовить три объекта из source до 32 МиБ/16×1024×1024 пикселей и квадратного selection после EXIF.
* Сохраняет исходные байты, WebP 256×256 quality=85 и нормализованный манифест; возвращает имя JSON и Bitmap превью.
* Исходный source не удаляется, возвращённый Bitmap не recycle-ится; оба ресурса остаются у вызывающего кода. */
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()
@@ -3,25 +3,38 @@ package com.utun.chat.data
import java.nio.ByteBuffer
import java.nio.ByteOrder
/** Форматы событий общего ядра; все идентификаторы сохраняют 64 бита, включая старший бит. */
/**
* Декодирование локальных JNI-событий аватаров: 49 — файловый ответ, 50 — прогресс передачи,
* 51/52 — результат публикации ссылки профиля/аватара группы, 53 — снимок аватара группы.
* Числа LE; UID и request ID хранят исходные 64 бита в Long, включая старший бит.
* Файловые события разбирает общий MemberFilesProtocol; неверный формат бросает исключение.
* Здесь проверяется framing: SHA256 байтов и геометрию изображения проверяют AvatarAssets/AvatarRepository.
*/
object AvatarProtocol {
/** Лимит пользовательского WebP-превью, не оригинала. */
const val MAX_USER = 1024 * 1024
const val MAX_GROUP = 16 * 1024
const val MAX_ORIGINAL = 32 * 1024 * 1024
const val MAX_MANIFEST = 4096
private val filePattern = Regex("[0-9a-f]{64}\\.json")
private val objectPattern = Regex("[0-9a-f]{64}\\.(png|jpg|webp|json)")
/** Имя пользовательского манифеста: 64 lowercase hex + .json, без пути. */
fun validFile(file: String): Boolean = filePattern.matches(file)
/** Имя объекта: тот же хеш с .png/.jpg/.webp/.json; содержимое здесь не проверяется. */
fun validObject(file: String): Boolean = objectPattern.matches(file)
// channel_id в БД/событиях — десятичный u64; avatar_server в userinfo — hex.
// Ошибка преобразования возвращает 0; вызывающий код также отклоняет нулевой ID.
fun groupId(id: String): Long = id.toULongOrNull(10)?.toLong() ?: 0
fun node(id: String): Long = id.toULongOrNull(16)?.toLong() ?: 0
fun hex(id: Long): String = id.toULong().toString(16).padStart(16, '0')
fun hash(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it.toInt() and 255) }
private fun buffer(data: ByteArray) = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
/** Снимок 53: canEdit — наличие локального приватного ключа; failed — ошибка чтения core.
* Пустое image допустимо при отсутствии/удалении аватара; окончательный допуск публикации проверяет core. */
data class Group(val id: Long, val group: Long, val version: Long, val hash: String,
val canEdit: Boolean, val failed: Boolean, val image: ByteArray)
/** Заголовок 60 байт: id/group/version:u64, hash:32, canEdit/failed:u8, size:u16, затем image до 16 КиБ. */
fun group(data: ByteArray): Group {
require(data.size in 60..60 + MAX_GROUP) { "group snapshot length=${data.size}" }
val b = buffer(data)
@@ -33,24 +46,31 @@ object AvatarProtocol {
return Group(id, group, version, hash(hash), canEdit != 0, failed != 0, ByteArray(size).also { b.get(it) })
}
/** Общий результат: поля, отсутствующие в данном событии, имеют значения по умолчанию.
* status=0 — успех; 49/50 используют MF-статус, 51/52 — failed (0/1). */
data class Reply(val id: Long, val group: Long, val server: Long = 0, val owner: Long = 0,
val command: Int = 0, val status: Int = 0, val size: Long = 0, val hash: String = "", val fileType: Int = 0,
val cursor: Long = 0, val version: ByteArray = byteArrayOf(), val more: Boolean = false,
val path: String = "", val payload: ByteArray = byteArrayOf())
/** Событие 49: group/server:u64 перед файловым ответом протокола v4; hash переводится в hex. */
fun response(data: ByteArray): Reply {
val r = MemberFilesProtocol.response(data)
return Reply(r.id, r.group, r.server, r.owner, r.command, r.status, r.size, hash(r.hash), r.type,
r.cursor, r.version, r.more, r.path, r.payload)
}
/** Имя JSON-манифеста и mtime файлового сервера (секунды Unix). */
data class Entry(val file: String, val mtime: Long)
/** Из LIST оставить только обычные файлы манифестов размером 1..4096 байт, сохраняя порядок страницы. */
fun entries(reply: Reply): List<Entry> {
return MemberFilesProtocol.entries(reply.payload).filter { it.type == 1 && validFile(it.name) && it.size in 1..MAX_MANIFEST }
.map { Entry(it.name, it.mtime) }
}
/** Событие 50, ровно 51 байт; null для промежуточного прогресса, Reply только при finished, включая ошибки. */
fun transfer(data: ByteArray): Reply? {
val r = MemberFilesProtocol.progress(data)
return if (r.finished) Reply(r.id, r.group, r.server, r.owner, status = r.status, size = r.total) else null
}
/** Событие 51/52, ровно 17 байт: id/group:u64 и failed:u8 (0/1). */
fun published(data: ByteArray): Reply {
require(data.size == 17 && data[16].toInt() in 0..1) { "invalid publication result" }
val b = buffer(data)
@@ -15,12 +15,30 @@ import java.io.File
import java.io.IOException
import java.security.MessageDigest
/** Хранитель с подписанной storageRole; group — одна из его CHAT-групп, nodeId хранит исходный u64. */
data class AvatarStorage(val group: String, val nodeId: Long, val name: String)
/** Метаданные снимка группы; hasImage независимо от наличия декодированного Bitmap в LRU. */
data class GroupAvatarState(val version: Long = 0, val hash: String = "", val canEdit: Boolean = false,
val loading: Boolean = false, val error: Boolean = false, val hasImage: Boolean = false)
/** SAVED: у группы получен успешный ответ core, у профиля сохранено локальное намерение; репликация ещё может ожидать. */
enum class AvatarEditState { IDLE, WORKING, SAVED, ERROR }
/** Один владелец запросов и кеша на приложение. Все карты/StateFlow изменяются только на main. */
/**
* Один репозиторий аватаров на приложение; публичные методы, включая suspend, вызываются на main.
* AppEventHandler сначала обновляет MemberRepository, затем передаёт сюда живые события без UI replay.
* Старт по 9/12 привязывает аккаунт; stop/13 меняет generation, отменяет задачи/файловые запросы
* и очищает память. Поздние результаты прежней generation не должны применяться к новому аккаунту.
*
* Пользовательские объекты загружаются из member_files в filesDir/avatars/<nodeId hex> с проверкой SHA256;
* одинаковые имена делят загрузку, последний уходящий waiter отменяет незавершённую операцию.
* Две общие transfer-позиции ограничивают загрузки объектов и репликации; IO/декодирование вынесены на Dispatchers.IO.
* LRU Bitmap — 32 МиБ. После загрузки набора prune пытается сократить дисковые объекты до 100 МиБ,
* сохраняя текущий профиль; это очистка кеша, не жёсткая квота каталога.
*
* Профиль сохраняется как локальное намерение в SharedPreferences и публикуется отдельно в каждой
* подходящей группе после загрузки оригинала, превью и манифеста. Аватар группы запрашивается/публикуется
* отдельным core API без member_files. Каждый ожидаемый JNI-ответ имеет timeout 60 с и проверку контекста.
*/
class AvatarRepository(context: Context, private val memberRepository: MemberRepository) {
private val app = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@@ -33,6 +51,7 @@ class AvatarRepository(context: Context, private val memberRepository: MemberRep
override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount
}
private val _revision = MutableStateFlow(0L)
/** Сигнал перечитать синхронные Bitmap-геттеры после изменения кеша/ссылок/аккаунта. */
val revision: StateFlow<Long> = _revision
val members: StateFlow<Map<MemberKey, ChatMember>> = memberRepository.members
private data class AvatarReference(val server: Long, val file: String, val deleted: Boolean, val storage: Boolean)
@@ -50,10 +69,13 @@ class AvatarRepository(context: Context, private val memberRepository: MemberRep
private val pending = mutableMapOf<Long, Pending>()
private var defaultAvatar: String? = null
private val foreground = MutableStateFlow(false)
/** Разрешить следующие попытки ensureUser/репликации; false не отменяет текущую передачу или запрос галереи. */
fun setForeground(active: Boolean) { foreground.value = active }
val accountId: Long get() = account
/** Локальное намерение профиля имеет приоритет над подписанной ссылкой; "-" означает снятый профиль. */
fun profileBitmap(): Bitmap? = if (defaultAvatar == "-") null else
defaultAvatar?.substringAfter('/')?.let { cache.get(it) } ?: userBitmap(null, account)
/** Загрузить локальное превью сохранённого профиля; без своего манифеста использовать ссылку участника. */
fun ensureProfile() {
if (account == 0L) start()
val file = defaultAvatar?.substringAfter('/')
@@ -80,7 +102,9 @@ class AvatarRepository(context: Context, private val memberRepository: MemberRep
private fun directory(uid: Long) = File(app.filesDir, "avatars/${AvatarProtocol.hex(uid)}")
private fun digest(bytes: ByteArray) = AvatarProtocol.hash(MessageDigest.getInstance("SHA-256").digest(bytes))
private fun groupKey(group: String, s: GroupAvatarState) = "group:$group:${s.version}:${s.hash}"
/** Только кеш: запросов/I/O нет; null также возможен после вытеснения Bitmap из LRU. */
fun groupBitmap(group: String): Bitmap? = _groups.value[group]?.let { cache.get(groupKey(group, it)) }
/** Превью подписанной ссылки участника; group=null выбирает первую подходящую запись owner среди групп. */
fun userBitmap(group: String?, owner: Long): Bitmap? {
val member = member(group, owner)?.takeUnless { it.isDeleted } ?: return null
return if (member.avatarServer != 0L && AvatarProtocol.validFile(member.avatarFile)) cache.get(member.avatarFile) else null
@@ -89,6 +113,7 @@ class AvatarRepository(context: Context, private val memberRepository: MemberRep
members.value[MemberKey(group, owner)] else members.value.values.firstOrNull {
it.nodeId == owner && it.avatarServer != 0L && AvatarProtocol.validFile(it.avatarFile)
}
/** Неудалённые хранители из снимков, по одному на nodeId; online/достижимость не являются условием отбора. */
fun storageServers(): List<AvatarStorage> = members.value.mapNotNull { (k, m) ->
if (m.storageRole && !m.isDeleted) AvatarStorage(k.group, m.nodeId, m.name) else null
}.distinctBy { it.nodeId }
@@ -147,6 +172,7 @@ class AvatarRepository(context: Context, private val memberRepository: MemberRep
account = 0; defaultAvatar = null; cache.evictAll()
_groups.value = emptyMap(); _edits.value = emptyMap(); changed()
}
/** Main: отменить текущую сессию и очистить память, сохранив файлы/намерение; репозиторий допускает новый start. */
fun stop() { reset() }
private fun start() {
val uid = NativeLib.getMyNodeId()
@@ -190,6 +216,8 @@ class AvatarRepository(context: Context, private val memberRepository: MemberRep
}
}
/** Запросить снимок decimal group и декодировать JPEG до 128×128/16 КиБ с проверкой SHA256.
* Без force готовый снимок переиспользуется; force во время запроса ставит один повтор после его завершения. */
fun ensureGroup(group: String, force: Boolean = false) {
if (account == 0L) start()
if (account == 0L) return
@@ -229,6 +257,9 @@ class AvatarRepository(context: Context, private val memberRepository: MemberRep
}
}
/** Поставить фоновую загрузку превью актуальной ссылки owner; до попытки ждёт foreground.
* Обычные отказы повторяются с паузой 5..160 с и сменой источника того же хеша; отмена/timeout завершают задачу.
* Изменение/удаление ссылки прекращает старую загрузку; готовность сообщается через revision. */
fun ensureUser(group: String?, owner: Long) {
if (owner == 0L) return
if (account == 0L) start()
@@ -316,6 +347,9 @@ class AvatarRepository(context: Context, private val memberRepository: MemberRep
}
}
/** Main suspend: получить набор для m.avatarFile из group/его avatarServer, не ожидая foreground.
* Нужна подписанная storageRole сервера. original=true — полный кадр с уменьшением декодирования, false — превью.
* Ошибка/отмена передаётся вызывающему коду; превью может быть общим Bitmap LRU, recycle вызывать нельзя. */
suspend fun loadAvatar(group: String, m: ChatMember, original: Boolean): Bitmap {
val epoch=generation; val root=directory(account); val pinnedIntent=defaultAvatar
if (!original) cache.get(m.avatarFile)?.let { return it }
@@ -328,6 +362,7 @@ class AvatarRepository(context: Context, private val memberRepository: MemberRep
return bitmap
}
/** Группа и участник с действующей ссылкой для галереи; group=null ищет первую подходящую запись owner. */
fun gallerySource(group: String?, owner: Long): Pair<String,ChatMember>? {
val m=member(group,owner)?.takeUnless { it.isDeleted } ?: return null
if (m.avatarServer==0L || !AvatarProtocol.validFile(m.avatarFile)) return null
@@ -335,6 +370,9 @@ class AvatarRepository(context: Context, private val memberRepository: MemberRep
return source to m
}
/** Main suspend: собрать страницы LIST avatars с cursor/version; BUSY/STALE перезапускают чтение до трёх раз.
* Возвращает имена манифестов по убыванию mtime, затем по имени; отсутствующая текущая ссылка добавляется первой.
* Изображения не загружаются, foreground не ожидается; ошибки/отмена передаются вызывающему коду. */
suspend fun catalog(group: String, m: ChatMember): List<String> {
val epoch=generation; var restarts=0
while (true) {
@@ -388,6 +426,8 @@ class AvatarRepository(context: Context, private val memberRepository: MemberRep
}
}
/** Main: uri=null снимает аватар группы; иначе центральный квадрат кодируется JPEG 128×128 до 16 КиБ.
* expectedAccount защищает от старого picker; результат core приходит через edits["group:<id>"], затем обновляется снимок. */
fun editGroup(group: String, uri: Uri?, expectedAccount: Long) = edit("group:$group", expectedAccount) { epoch ->
val bytes = if (uri == null) ByteArray(0) else prepareGroup(uri)
alive(epoch)
@@ -397,6 +437,9 @@ class AvatarRepository(context: Context, private val memberRepository: MemberRep
ensureGroup(group, true)
}
/** Main: принять во владение временный source и crop после EXIF; source удаляется и при отказе/старом аккаунте.
* Для установки нужен server из storageServers и crop; source=null сохраняет снятие аватара, server/crop игнорируются.
* SAVED означает локальное сохранение; загрузки файлов и публикации ссылок продолжаются отдельно по группам. */
fun editProfile(server: Long, source: File?, expectedAccount: Long, crop: AvatarCrop? = null) = edit("profile", expectedAccount,source) { epoch ->
val uid = account
val prepared=if (source==null) null else try {