diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt index a9d1a8cf..07ee4023 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt @@ -13,6 +13,7 @@ import com.utun.chat.data.IncomingCallManager import com.utun.chat.data.TelecomCallManager import com.utun.chat.data.ChatEventCallback import com.utun.chat.data.CallController +import com.utun.chat.data.AvatarRepository import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import java.nio.ByteBuffer @@ -24,6 +25,8 @@ object AppEventHandler : ChatEventCallback { val events: SharedFlow> = _events override fun onEvent(type: Int, data: ByteArray?) { + if (type in listOf(4, 8, 9, 12, 13, 23, 24, 25, 31) || type in 49..53) + mainHandler.post { ChatApplication.instance.avatars.onEvent(type, data) } if (type == 1 || type == 32) playMessageSound(type, data) if (type == 12 || type == 13 || type in 34..40 || type == 42 || type == 44) { mainHandler.post { CallController.onEvent(type, data) } @@ -51,12 +54,14 @@ object AppEventHandler : ChatEventCallback { class ChatApplication : Application() { lateinit var logManager: LogManager lateinit var configProvider: ConfigProvider + lateinit var avatars: AvatarRepository override fun onCreate() { super.onCreate() instance = this logManager = LogManager configProvider = ConfigProvider(this) + avatars = AvatarRepository(this) /* Notification sound state: global toggle + per-group mute */ NotificationSound.enabled = configProvider.getSoundEnabled() diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarProtocol.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarProtocol.kt index 4913e72c..8cbc27da 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarProtocol.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarProtocol.kt @@ -28,7 +28,7 @@ object AvatarProtocol { } 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 command: Int = 0, val status: Int = 0, val size: Long = 0, val hash: String = "", val fileType: Int = 0) fun response(data: ByteArray): Reply { require(data.size >= 141) { "file response length=${data.size}" } val b = buffer(data) @@ -38,12 +38,13 @@ object AvatarProtocol { val id = b.long; val owner = b.long b.long; val size = b.long b.position(16 + 77); val hash = ByteArray(32); b.get(hash) + b.position(16 + 117); val fileType = b.get().toInt() and 255 b.position(16 + 119) val path = b.short.toInt() and 65535; val target = b.short.toInt() and 65535; val payload = b.short.toInt() and 65535 require(path <= 1024 && target <= 1024 && payload <= 8192 && b.remaining() == path + target + payload) { "invalid file response body lengths" } - return Reply(id, group, server, owner, command, status, size, hash(hash)) + return Reply(id, group, server, owner, command, status, size, hash(hash), fileType) } fun transfer(data: ByteArray): Reply? { require(data.size == 51) { "transfer length=${data.size}" } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarRepository.kt new file mode 100644 index 00000000..dbd2b6ef --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarRepository.kt @@ -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(32 * 1024 * 1024) { + override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount + } + private val _revision = MutableStateFlow(0L) + val revision: StateFlow = _revision + private val _members = MutableStateFlow>(emptyMap()) + val members: StateFlow> = _members + private val _groups = MutableStateFlow>(emptyMap()) + val groups: StateFlow> = _groups + private val _edits = MutableStateFlow>(emptyMap()) + val edits: StateFlow> = _edits + private val loads = mutableSetOf() + private val memberRequests = mutableMapOf() + private val editTokens = mutableMapOf() + private val replications = mutableSetOf() + private val dirtyGroups = mutableSetOf() + private val pending = mutableMapOf() + 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) + 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 = _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() + 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") } + } +}