Browse Source

android: load and cache user and signed group avatars asynchronously

master
evgeny 2 weeks ago
parent
commit
22aa5d47a5
  1. 5
      tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt
  2. 5
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarProtocol.kt
  3. 435
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarRepository.kt

5
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.TelecomCallManager
import com.utun.chat.data.ChatEventCallback import com.utun.chat.data.ChatEventCallback
import com.utun.chat.data.CallController import com.utun.chat.data.CallController
import com.utun.chat.data.AvatarRepository
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import java.nio.ByteBuffer import java.nio.ByteBuffer
@ -24,6 +25,8 @@ object AppEventHandler : ChatEventCallback {
val events: SharedFlow<Pair<Int, ByteArray?>> = _events val events: SharedFlow<Pair<Int, ByteArray?>> = _events
override fun onEvent(type: Int, data: ByteArray?) { 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 == 1 || type == 32) playMessageSound(type, data)
if (type == 12 || type == 13 || type in 34..40 || type == 42 || type == 44) { if (type == 12 || type == 13 || type in 34..40 || type == 42 || type == 44) {
mainHandler.post { CallController.onEvent(type, data) } mainHandler.post { CallController.onEvent(type, data) }
@ -51,12 +54,14 @@ object AppEventHandler : ChatEventCallback {
class ChatApplication : Application() { class ChatApplication : Application() {
lateinit var logManager: LogManager lateinit var logManager: LogManager
lateinit var configProvider: ConfigProvider lateinit var configProvider: ConfigProvider
lateinit var avatars: AvatarRepository
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
logManager = LogManager logManager = LogManager
configProvider = ConfigProvider(this) configProvider = ConfigProvider(this)
avatars = AvatarRepository(this)
/* Notification sound state: global toggle + per-group mute */ /* Notification sound state: global toggle + per-group mute */
NotificationSound.enabled = configProvider.getSoundEnabled() NotificationSound.enabled = configProvider.getSoundEnabled()

5
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, 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 { fun response(data: ByteArray): Reply {
require(data.size >= 141) { "file response length=${data.size}" } require(data.size >= 141) { "file response length=${data.size}" }
val b = buffer(data) val b = buffer(data)
@ -38,12 +38,13 @@ object AvatarProtocol {
val id = b.long; val owner = b.long val id = b.long; val owner = b.long
b.long; val size = b.long b.long; val size = b.long
b.position(16 + 77); val hash = ByteArray(32); b.get(hash) 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) 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 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) { require(path <= 1024 && target <= 1024 && payload <= 8192 && b.remaining() == path + target + payload) {
"invalid file response body lengths" "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? { fun transfer(data: ByteArray): Reply? {
require(data.size == 51) { "transfer length=${data.size}" } require(data.size == 51) { "transfer length=${data.size}" }

435
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<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…
Cancel
Save