3 changed files with 443 additions and 2 deletions
@ -0,0 +1,435 @@ |
|||||||
|
package com.utun.chat.data |
||||||
|
|
||||||
|
import android.content.Context |
||||||
|
import android.graphics.Bitmap |
||||||
|
import android.graphics.BitmapFactory |
||||||
|
import android.net.Uri |
||||||
|
import android.util.LruCache |
||||||
|
import kotlinx.coroutines.* |
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow |
||||||
|
import kotlinx.coroutines.flow.StateFlow |
||||||
|
import kotlinx.coroutines.sync.Semaphore |
||||||
|
import kotlinx.coroutines.sync.withPermit |
||||||
|
import java.io.File |
||||||
|
import java.io.IOException |
||||||
|
import java.security.MessageDigest |
||||||
|
import java.util.concurrent.atomic.AtomicLong |
||||||
|
|
||||||
|
data class AvatarMemberKey(val group: String, val owner: Long) |
||||||
|
data class AvatarStorage(val group: String, val nodeId: Long, val name: String) |
||||||
|
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) |
||||||
|
enum class AvatarEditState { IDLE, WORKING, SAVED, ERROR } |
||||||
|
|
||||||
|
/** Один владелец запросов и кеша на приложение. Все карты/StateFlow изменяются только на main. */ |
||||||
|
class AvatarRepository(context: Context) { |
||||||
|
private val app = context.applicationContext |
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) |
||||||
|
private val transfers = Semaphore(2) |
||||||
|
private val ids = AtomicLong(System.nanoTime()) |
||||||
|
private var generation = 0L |
||||||
|
private var account = 0L |
||||||
|
private val prefs = app.getSharedPreferences("avatars", Context.MODE_PRIVATE) |
||||||
|
private val cache = object : LruCache<String, Bitmap>(32 * 1024 * 1024) { |
||||||
|
override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount |
||||||
|
} |
||||||
|
private val _revision = MutableStateFlow(0L) |
||||||
|
val revision: StateFlow<Long> = _revision |
||||||
|
private val _members = MutableStateFlow<Map<AvatarMemberKey, ChatMember>>(emptyMap()) |
||||||
|
val members: StateFlow<Map<AvatarMemberKey, ChatMember>> = _members |
||||||
|
private val _groups = MutableStateFlow<Map<String, GroupAvatarState>>(emptyMap()) |
||||||
|
val groups: StateFlow<Map<String, GroupAvatarState>> = _groups |
||||||
|
private val _edits = MutableStateFlow<Map<String, AvatarEditState>>(emptyMap()) |
||||||
|
val edits: StateFlow<Map<String, AvatarEditState>> = _edits |
||||||
|
private val loads = mutableSetOf<String>() |
||||||
|
private val memberRequests = mutableMapOf<String, Long>() |
||||||
|
private val editTokens = mutableMapOf<String, Long>() |
||||||
|
private val replications = mutableSetOf<String>() |
||||||
|
private val dirtyGroups = mutableSetOf<String>() |
||||||
|
private val pending = mutableMapOf<Long, Pending>() |
||||||
|
private var defaultAvatar: String? = null |
||||||
|
|
||||||
|
private data class Pending(val type: Int, val group: Long, val server: Long, val owner: Long, |
||||||
|
val command: Int, val result: CompletableDeferred<ByteArray>) |
||||||
|
private fun log(level: String, message: String) = LogManager.addLog(level, "Avatar", message) |
||||||
|
private fun changed() { _revision.value++ } |
||||||
|
private fun alive(epoch: Long) { if (generation != epoch || account == 0L) throw CancellationException("avatar session changed") } |
||||||
|
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}" |
||||||
|
fun groupBitmap(group: String): Bitmap? = _groups.value[group]?.let { cache.get(groupKey(group, it)) } |
||||||
|
fun userBitmap(group: String?, owner: Long): Bitmap? { |
||||||
|
val member = member(group, owner) ?: return null |
||||||
|
return if (member.avatarServer != 0L && AvatarProtocol.validFile(member.avatarFile)) cache.get(member.avatarFile) else null |
||||||
|
} |
||||||
|
private fun member(group: String?, owner: Long): ChatMember? = if (group != null) |
||||||
|
_members.value[AvatarMemberKey(group, owner)] else _members.value.values.firstOrNull { |
||||||
|
it.nodeId == owner && it.avatarServer != 0L && AvatarProtocol.validFile(it.avatarFile) |
||||||
|
} |
||||||
|
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 } |
||||||
|
|
||||||
|
/** Вызывается из AppEventHandler, без replay UI-подписчиков. */ |
||||||
|
fun onEvent(type: Int, data: ByteArray?) { |
||||||
|
if (type == 13) { reset(); return } |
||||||
|
if (type == 9 || type == 12) { start(); return } |
||||||
|
try { |
||||||
|
if (type in 49..53 && data != null) { |
||||||
|
val reply = when (type) { |
||||||
|
49 -> AvatarProtocol.response(data) |
||||||
|
50 -> AvatarProtocol.transfer(data) ?: return |
||||||
|
51, 52 -> AvatarProtocol.published(data) |
||||||
|
else -> AvatarProtocol.group(data).let { AvatarProtocol.Reply(it.id, it.group) } |
||||||
|
} |
||||||
|
val p = pending[reply.id] ?: return |
||||||
|
require(p.type == type && p.group == reply.group && |
||||||
|
(type !in 49..50 || (p.server == reply.server && p.owner == reply.owner && |
||||||
|
(type != 49 || p.command == reply.command)))) { "response context mismatch id=${reply.id} type=$type" } |
||||||
|
p.result.complete(data) |
||||||
|
} else if (type == 23 || type == 24 || type == 25) { |
||||||
|
require(data != null && data.isNotEmpty()) { "missing member event" } |
||||||
|
val length = data[0].toInt() and 255 |
||||||
|
require(length > 0 && data.size >= 1 + length) { "invalid member channel header" } |
||||||
|
val group = String(data, 1, length) |
||||||
|
val updated = _members.value.toMutableMap() |
||||||
|
if (type == 25) { |
||||||
|
require(data.size == 1 + length + 8) { "invalid member removal" } |
||||||
|
val owner = java.nio.ByteBuffer.wrap(data, length + 1, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).long |
||||||
|
updated.remove(AvatarMemberKey(group, owner)) |
||||||
|
} else { |
||||||
|
val countSize = if (type == 23) 2 else 1 |
||||||
|
require(data.size >= 1 + length + countSize) { "missing member count" } |
||||||
|
val count = (data[1 + length].toInt() and 255) + |
||||||
|
if (type == 23) ((data[2 + length].toInt() and 255) shl 8) else 0 |
||||||
|
val offset = 1 + length + countSize |
||||||
|
require(data.size == offset + count * MEMBER_WIRE_SIZE) { "invalid member snapshot size" } |
||||||
|
if (type == 23) { |
||||||
|
updated.keys.removeAll { it.group == group }; memberRequests.remove(group) |
||||||
|
} |
||||||
|
repeat(count) { i -> |
||||||
|
val m = requireNotNull(parseMemberWire(data, offset + i * MEMBER_WIRE_SIZE)).toChatMember() |
||||||
|
if (!m.isDeleted) updated[AvatarMemberKey(group, m.nodeId)] = m |
||||||
|
else updated.remove(AvatarMemberKey(group, m.nodeId)) |
||||||
|
} |
||||||
|
} |
||||||
|
_members.value = updated |
||||||
|
changed(); replicate(group) |
||||||
|
} else if (type == 4 || type == 8 || type == 31) { |
||||||
|
if (data != null && data.isNotEmpty()) { |
||||||
|
val length = data[0].toInt() and 255 |
||||||
|
require(length > 0 && data.size >= length + 1) { "invalid channel event" } |
||||||
|
val group = String(data, 1, length) |
||||||
|
if (type == 31) { |
||||||
|
_members.value = _members.value.filterKeys { it.group != group } |
||||||
|
_groups.value -= group; dirtyGroups.remove(group); changed(); return |
||||||
|
} |
||||||
|
if (_groups.value.containsKey(group)) ensureGroup(group, true) |
||||||
|
requestMembers(group) |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (e: Exception) { log("ERROR", "Rejected event type=$type bytes=${data?.size}: ${e.message}") } |
||||||
|
} |
||||||
|
|
||||||
|
private fun reset() { |
||||||
|
log("INFO", "Reset account=${AvatarProtocol.hex(account)} pending=${pending.size} cacheBytes=${cache.size()}") |
||||||
|
generation++ |
||||||
|
scope.coroutineContext.cancelChildren() |
||||||
|
pending.clear(); loads.clear(); replications.clear(); dirtyGroups.clear(); memberRequests.clear(); editTokens.clear() |
||||||
|
account = 0; defaultAvatar = null; cache.evictAll() |
||||||
|
_members.value = emptyMap(); _groups.value = emptyMap(); _edits.value = emptyMap(); changed() |
||||||
|
} |
||||||
|
private fun start() { |
||||||
|
val uid = NativeLib.getMyNodeId() |
||||||
|
if (uid == 0L || uid == account) return |
||||||
|
reset(); account = uid |
||||||
|
defaultAvatar = prefs.getString(AvatarProtocol.hex(uid), null) |
||||||
|
log("INFO", "Start account=${AvatarProtocol.hex(uid)} default=$defaultAvatar memoryLimit=32MiB diskLimit=100MiB transfers=2") |
||||||
|
val epoch = generation |
||||||
|
scope.launch { |
||||||
|
val channels = withContext(Dispatchers.IO) { ChatRepository().getChannels() } |
||||||
|
alive(epoch) |
||||||
|
channels.forEach { requestMembers(it.id) } |
||||||
|
} |
||||||
|
} |
||||||
|
private fun requestMembers(group: String) { |
||||||
|
if (account == 0L) start() |
||||||
|
if (account == 0L || AvatarProtocol.node(group) == 0L) return |
||||||
|
val now = android.os.SystemClock.elapsedRealtime() |
||||||
|
if (now - (memberRequests[group] ?: -5000) < 5000) return |
||||||
|
memberRequests[group] = now |
||||||
|
if (!NativeLib.requestMemberList(group)) log("WARN", "Member snapshot enqueue failed group=$group") |
||||||
|
} |
||||||
|
|
||||||
|
private suspend fun request(type: Int, group: String, server: Long = 0, owner: Long = 0, command: Int = 0, |
||||||
|
enqueue: (Long, Long) -> Boolean): ByteArray { |
||||||
|
val id = ids.incrementAndGet(); val gid = AvatarProtocol.node(group) |
||||||
|
val result = CompletableDeferred<ByteArray>() |
||||||
|
pending[id] = Pending(type, gid, server, owner, command, result) |
||||||
|
var complete = false |
||||||
|
try { |
||||||
|
if (!enqueue(id, gid)) throw IOException("native enqueue failed id=$id type=$type group=$group") |
||||||
|
val data = withTimeout(60_000) { result.await() } |
||||||
|
complete = true |
||||||
|
return data |
||||||
|
} finally { |
||||||
|
pending.remove(id) |
||||||
|
if (!complete && type in 49..50 && NativeLib.isReady()) NativeLib.cancelAvatar(id) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun ensureGroup(group: String, force: Boolean = false) { |
||||||
|
if (account == 0L) start() |
||||||
|
if (account == 0L || AvatarProtocol.node(group) == 0L) return |
||||||
|
if ("group:$group" in loads) { if (force) dirtyGroups.add(group); return } |
||||||
|
val current = _groups.value[group] |
||||||
|
if (!force && current != null && !current.error && |
||||||
|
(!current.hasImage || groupBitmap(group) != null)) return |
||||||
|
loads.add("group:$group") |
||||||
|
_groups.value += group to (current ?: GroupAvatarState()).copy(loading = true) |
||||||
|
val epoch = generation |
||||||
|
scope.launch { |
||||||
|
try { |
||||||
|
val record = AvatarProtocol.group(request(53, group) { id, gid -> NativeLib.requestGroupAvatar(id, gid) }) |
||||||
|
require(!record.failed) { "core rejected stored group avatar" } |
||||||
|
val state = GroupAvatarState(record.version, record.hash, record.canEdit, hasImage = record.image.isNotEmpty()) |
||||||
|
val bitmap = if (record.image.isEmpty()) null else withContext(Dispatchers.IO) { |
||||||
|
decode(record.image, record.hash, true) |
||||||
|
} |
||||||
|
alive(epoch) |
||||||
|
if (!_groups.value.containsKey(group)) return@launch |
||||||
|
if (bitmap != null) cache.put(groupKey(group, state), bitmap) |
||||||
|
_groups.value += group to state; changed() |
||||||
|
log("DEBUG", "Group snapshot ready group=$group version=${state.version} bytes=${record.image.size} edit=${state.canEdit}") |
||||||
|
} catch (e: CancellationException) { throw e } |
||||||
|
catch (e: Exception) { |
||||||
|
log("ERROR", "Group load failed group=$group: ${e.message}") |
||||||
|
_groups.value += group to (current ?: GroupAvatarState()).copy(error = true) |
||||||
|
} finally { |
||||||
|
if (generation == epoch) { |
||||||
|
loads.remove("group:$group") |
||||||
|
if (dirtyGroups.remove(group) && _groups.value.containsKey(group)) ensureGroup(group, true) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun ensureUser(group: String?, owner: Long) { |
||||||
|
if (owner == 0L) return |
||||||
|
if (account == 0L) start() |
||||||
|
if (group != null && member(group, owner) == null) requestMembers(group) |
||||||
|
val m = member(group, owner) ?: return |
||||||
|
if (m.avatarServer == 0L || !AvatarProtocol.validFile(m.avatarFile) || cache.get(m.avatarFile) != null) return |
||||||
|
val sourceGroup = group ?: _members.value.entries.firstOrNull { it.value == m }?.key?.group ?: return |
||||||
|
val key = m.avatarFile |
||||||
|
if (!loads.add(key)) return |
||||||
|
val epoch = generation; val uid = account |
||||||
|
scope.launch { |
||||||
|
var retry = 0 |
||||||
|
try { |
||||||
|
while (true) { |
||||||
|
alive(epoch) |
||||||
|
try { |
||||||
|
val bitmap = transfers.withPermit { |
||||||
|
val root = directory(uid); val file = File(root, key) |
||||||
|
withContext(Dispatchers.IO) { |
||||||
|
if (!root.isDirectory && !root.mkdirs()) throw IOException("cannot create cache directory") |
||||||
|
if (file.isFile) { |
||||||
|
try { decode(file.readBytes(), key.removeSuffix(".png"), false).also { file.setLastModified(System.currentTimeMillis()) } } |
||||||
|
catch (e: Exception) { log("WARN", "Invalid cached avatar file=$key: ${e.message}"); file.delete(); null } |
||||||
|
} else null |
||||||
|
} ?: download(sourceGroup, m, root) |
||||||
|
} |
||||||
|
alive(epoch); cache.put(key, bitmap); changed() |
||||||
|
log("INFO", "User image ready group=$sourceGroup owner=${AvatarProtocol.hex(owner)} file=$key retry=$retry") |
||||||
|
break |
||||||
|
} catch (e: CancellationException) { throw e } |
||||||
|
catch (e: Exception) { |
||||||
|
log("WARN", "User image failed group=$sourceGroup owner=${AvatarProtocol.hex(owner)} server=${AvatarProtocol.hex(m.avatarServer)} file=$key retry=$retry: ${e.message}") |
||||||
|
if (member(group, owner)?.avatarFile != key) break |
||||||
|
delay((5000L shl retry.coerceAtMost(5))); retry++ |
||||||
|
} |
||||||
|
} |
||||||
|
} finally { if (generation == epoch) loads.remove(key) } |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private suspend fun download(group: String, m: ChatMember, root: File): Bitmap { |
||||||
|
require(_members.value[AvatarMemberKey(group, m.avatarServer)]?.storageRole == true) { "avatar server has no signed storage role" } |
||||||
|
val temp = File(root, ".download-${ids.incrementAndGet()}") |
||||||
|
try { |
||||||
|
val reply = AvatarProtocol.transfer(request(50, group, m.avatarServer, m.nodeId) { id, gid -> |
||||||
|
NativeLib.transferAvatar(id, gid, m.avatarServer, m.nodeId, "avatars/${m.avatarFile}", temp.path) |
||||||
|
}) ?: throw IOException("missing final transfer result") |
||||||
|
require(reply.status == 0 && reply.size in 1..AvatarProtocol.MAX_USER) { "download status=${reply.status} size=${reply.size}" } |
||||||
|
return withContext(Dispatchers.IO) { |
||||||
|
val bitmap = decode(temp.readBytes(), m.avatarFile.removeSuffix(".png"), false) |
||||||
|
val target = File(root, m.avatarFile) |
||||||
|
if (!temp.renameTo(target)) throw IOException("cannot publish validated cache file") |
||||||
|
prune(root) |
||||||
|
bitmap |
||||||
|
} |
||||||
|
} finally { withContext(NonCancellable + Dispatchers.IO) { if (temp.exists() && !temp.delete()) log("WARN", "Cannot delete temp=$temp") } } |
||||||
|
} |
||||||
|
|
||||||
|
/** Проверяем размер, хеш и геометрию до выделения декодированного изображения. */ |
||||||
|
private fun decode(bytes: ByteArray, hash: String, group: Boolean): Bitmap { |
||||||
|
require(bytes.size in 1..if (group) AvatarProtocol.MAX_GROUP else AvatarProtocol.MAX_USER) { "image byte limit" } |
||||||
|
require(digest(bytes) == hash) { "image SHA256 mismatch" } |
||||||
|
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } |
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) |
||||||
|
val edge = if (group) 128 else 512 |
||||||
|
require(options.outWidth in 1..edge && options.outHeight == options.outWidth && |
||||||
|
options.outMimeType == if (group) "image/jpeg" else "image/png") { |
||||||
|
"invalid image ${options.outMimeType} ${options.outWidth}x${options.outHeight} limit=$edge" |
||||||
|
} |
||||||
|
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: throw IOException("image decode failed") |
||||||
|
} |
||||||
|
private fun prune(root: File) { |
||||||
|
val pinned = defaultAvatar?.substringAfter('/') |
||||||
|
val files = root.listFiles()?.filter { AvatarProtocol.validFile(it.name) }?.sortedBy { it.lastModified() } ?: return |
||||||
|
var size = files.sumOf { it.length() } |
||||||
|
for (file in files) if (size > 100L * 1024 * 1024 && file.name != pinned) { |
||||||
|
val bytes = file.length() |
||||||
|
if (file.delete()) size -= bytes else log("WARN", "Cache eviction failed file=$file") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun editGroup(group: String, uri: Uri?) = edit("group:$group") { epoch -> |
||||||
|
val bytes = if (uri == null) ByteArray(0) else prepare(uri, true) |
||||||
|
alive(epoch) |
||||||
|
require(editTokens["group:$group"] == epoch) { "group edit superseded" } |
||||||
|
val reply = AvatarProtocol.published(request(52, group) { id, gid -> NativeLib.setGroupAvatar(id, gid, bytes) }) |
||||||
|
require(reply.status == 0) { "group publication rejected" } |
||||||
|
ensureGroup(group, true) |
||||||
|
} |
||||||
|
|
||||||
|
fun editProfile(server: Long, uri: Uri?) = edit("profile") { epoch -> |
||||||
|
val uid = account |
||||||
|
val bytes = if (uri == null) null else prepare(uri, false) |
||||||
|
alive(epoch) |
||||||
|
if (bytes != null) { |
||||||
|
require(server != 0L && storageServers().any { it.nodeId == server }) { "no eligible storage server" } |
||||||
|
val file = "${digest(bytes)}.png" |
||||||
|
withContext(Dispatchers.IO) { |
||||||
|
val root = directory(uid) |
||||||
|
if (!root.isDirectory && !root.mkdirs()) throw IOException("cannot create profile cache") |
||||||
|
File(root, file).writeBytes(bytes) |
||||||
|
} |
||||||
|
alive(epoch) |
||||||
|
defaultAvatar = "${AvatarProtocol.hex(server)}/$file" |
||||||
|
cache.put(file, withContext(Dispatchers.IO) { decode(bytes, file.removeSuffix(".png"), false) }) |
||||||
|
} else defaultAvatar = "-" |
||||||
|
alive(epoch) |
||||||
|
require(prefs.edit().putString(AvatarProtocol.hex(uid), defaultAvatar).commit()) { "cannot persist avatar intent" } |
||||||
|
changed() |
||||||
|
_members.value.keys.map { it.group }.distinct().forEach { replicate(it) } |
||||||
|
} |
||||||
|
|
||||||
|
private fun edit(key: String, block: suspend (Long) -> Unit) { |
||||||
|
if (account == 0L) start() |
||||||
|
if (account == 0L || _edits.value[key] == AvatarEditState.WORKING) return |
||||||
|
val epoch = generation; editTokens[key] = epoch |
||||||
|
_edits.value += key to AvatarEditState.WORKING |
||||||
|
scope.launch { |
||||||
|
try { |
||||||
|
block(epoch); alive(epoch); _edits.value += key to AvatarEditState.SAVED |
||||||
|
log("INFO", "Edit accepted target=$key") |
||||||
|
} catch (e: CancellationException) { throw e } |
||||||
|
catch (e: Exception) { log("ERROR", "Edit failed target=$key: ${e.message}"); _edits.value += key to AvatarEditState.ERROR } |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private fun replicate(group: String) { |
||||||
|
val desired = defaultAvatar ?: return |
||||||
|
val mine = _members.value[AvatarMemberKey(group, account)] ?: return |
||||||
|
val server = if (desired == "-") 0 else AvatarProtocol.node(desired.substringBefore('/')) |
||||||
|
val file = if (server == 0L) "" else desired.substringAfter('/') |
||||||
|
if (mine.avatarServer == server && mine.avatarFile == file) return |
||||||
|
if (server != 0L && _members.value[AvatarMemberKey(group, server)]?.storageRole != true) return |
||||||
|
if (!replications.add(group)) return |
||||||
|
val epoch = generation; val uid = account |
||||||
|
scope.launch { |
||||||
|
var retry = 0 |
||||||
|
try { |
||||||
|
while (defaultAvatar == desired) { |
||||||
|
try { |
||||||
|
alive(epoch) |
||||||
|
transfers.withPermit { |
||||||
|
if (server != 0L) { |
||||||
|
val local = File(directory(uid), file) |
||||||
|
withContext(Dispatchers.IO) { decode(local.readBytes(), file.removeSuffix(".png"), false) } |
||||||
|
val mkdir = AvatarProtocol.response(request(49, group, server, uid, 8) { id, gid -> NativeLib.avatarMkdir(id, gid, server, uid) }) |
||||||
|
require(mkdir.status == 0 || mkdir.status == 5) { "mkdir status=${mkdir.status}" } |
||||||
|
val stat = AvatarProtocol.response(request(49, group, server, uid, 2) { id, gid -> NativeLib.avatarStat(id, gid, server, uid, "avatars/$file") }) |
||||||
|
if (stat.status == 2) { |
||||||
|
val upload = AvatarProtocol.transfer(request(50, group, server, uid) { id, gid -> |
||||||
|
NativeLib.transferAvatar(id, gid, server, uid, "avatars/$file", local.path, true) |
||||||
|
}) ?: throw IOException("missing upload result") |
||||||
|
require(upload.status == 0) { "upload status=${upload.status}" } |
||||||
|
} else require(stat.status == 0 && stat.fileType == 1 && stat.size == local.length()) { |
||||||
|
"stat status=${stat.status} type=${stat.fileType} bytes=${stat.size} expected=${local.length()}" |
||||||
|
} |
||||||
|
} |
||||||
|
alive(epoch) |
||||||
|
if (defaultAvatar != desired) return@withPermit |
||||||
|
val published = AvatarProtocol.published(request(51, group) { id, gid -> NativeLib.setMemberAvatar(id, gid, server, file) }) |
||||||
|
require(published.status == 0) { "member publication rejected" } |
||||||
|
log("INFO", "Profile published group=$group server=${AvatarProtocol.hex(server)} file=$file") |
||||||
|
} |
||||||
|
break |
||||||
|
} catch (e: CancellationException) { throw e } |
||||||
|
catch (e: Exception) { |
||||||
|
log("WARN", "Profile pending group=$group retry=$retry: ${e.message}") |
||||||
|
delay(5000L shl retry.coerceAtMost(5)); retry++ |
||||||
|
} |
||||||
|
} |
||||||
|
} finally { |
||||||
|
if (generation == epoch) { |
||||||
|
replications.remove(group) |
||||||
|
if (defaultAvatar != desired) replicate(group) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** Чтение URI, EXIF, crop и кодирование выполняются вне UI и потока ядра. */ |
||||||
|
private suspend fun prepare(uri: Uri, group: Boolean): ByteArray = withContext(Dispatchers.IO) { |
||||||
|
val temp = File.createTempFile("avatar-source-", ".image", app.cacheDir) |
||||||
|
try { |
||||||
|
app.contentResolver.openInputStream(uri)?.use { input -> |
||||||
|
temp.outputStream().use { output -> |
||||||
|
val chunk = ByteArray(8192); var total = 0 |
||||||
|
while (true) { |
||||||
|
val n = input.read(chunk); if (n < 0) break |
||||||
|
total += n; require(total <= 32 * 1024 * 1024) { "source image exceeds 32MiB" } |
||||||
|
output.write(chunk, 0, n) |
||||||
|
} |
||||||
|
} |
||||||
|
} ?: throw IOException("cannot read selected image") |
||||||
|
val (w, h) = PhotoUtil.orientedDimensions(temp.path) |
||||||
|
require(w > 0 && h > 0 && w.toLong() * h <= 100_000_000) { "invalid source dimensions $w x $h" } |
||||||
|
var sample = 1 |
||||||
|
while (maxOf(w, h) / sample > 1024) sample *= 2 |
||||||
|
val decoded = PhotoUtil.decodeOriented(temp.path, sample) ?: throw IOException("source decode failed") |
||||||
|
val edge = minOf(decoded.width, decoded.height) |
||||||
|
val crop = Bitmap.createBitmap(decoded, (decoded.width - edge) / 2, (decoded.height - edge) / 2, edge, edge) |
||||||
|
val scaled = Bitmap.createScaledBitmap(crop, if (group) 128 else 256, if (group) 128 else 256, true) |
||||||
|
try { |
||||||
|
for (quality in if (group) listOf(85, 70, 50, 35, 20) else listOf(100)) { |
||||||
|
val output = java.io.ByteArrayOutputStream() |
||||||
|
require(scaled.compress(if (group) Bitmap.CompressFormat.JPEG else Bitmap.CompressFormat.PNG, quality, output)) { |
||||||
|
"avatar compression failed" |
||||||
|
} |
||||||
|
val bytes = output.toByteArray() |
||||||
|
if (bytes.size <= if (group) AvatarProtocol.MAX_GROUP else AvatarProtocol.MAX_USER) return@withContext bytes |
||||||
|
} |
||||||
|
throw IOException("encoded avatar exceeds limit") |
||||||
|
} finally { |
||||||
|
if (scaled !== crop && scaled !== decoded) scaled.recycle() |
||||||
|
if (crop !== decoded) crop.recycle() |
||||||
|
decoded.recycle() |
||||||
|
} |
||||||
|
} finally { if (!temp.delete()) log("WARN", "Cannot delete source temp=$temp") } |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue