Document Android avatar storage and publication contracts
This commit is contained in:
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 {
|
||||
|
||||
Reference in new issue
Block a user