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 index 2c3fad86..49613e34 100644 --- 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 @@ -44,6 +44,7 @@ class AvatarRepository(context: Context) { private val _edits = MutableStateFlow>(emptyMap()) val edits: StateFlow> = _edits private val loads = mutableSetOf() + private val objectLoads = mutableMapOf>() private val memberRequests = mutableMapOf() private val editTokens = mutableMapOf() private val replications = mutableSetOf() @@ -64,7 +65,7 @@ class AvatarRepository(context: Context) { scope.launch { try { val bitmap = withContext(Dispatchers.IO) { - decode(readImage(File(directory(uid), file)), file.removeSuffix(".png"), false) + val root=directory(uid); AvatarAssets.image(root,AvatarAssets.record(root,file),false) } alive(epoch); cache.put(file, bitmap); changed() } catch (e: CancellationException) { throw e } @@ -162,7 +163,7 @@ class AvatarRepository(context: Context) { if (p.type in 49..50 && NativeLib.isReady()) { p.cancelled = true; NativeLib.cancelAvatar(id) } } scope.coroutineContext.cancelChildren() - pending.clear(); loads.clear(); replications.clear(); dirtyGroups.clear(); memberRequests.clear(); editTokens.clear() + pending.clear(); loads.clear(); objectLoads.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() } @@ -274,24 +275,7 @@ class AvatarRepository(context: Context) { if (candidates.isEmpty()) break val source = candidates[retry % candidates.size] 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(readImage(file), key.removeSuffix(".png"), false).also { - if (!file.setLastModified(System.currentTimeMillis())) log("WARN", "Cannot touch cache file=$file") - } - } - catch (e: Exception) { - log("WARN", "Invalid cached avatar file=$key: ${e.message}") - if (!file.delete()) log("WARN", "Cannot delete corrupt cache file=$file") - null - } - } else null - } ?: download(source.key.group, source.value, root) - } + val bitmap=loadAvatar(source.key.group,source.value,false) alive(epoch); cache.put(key, bitmap); changed() log("INFO", "User image ready group=$sourceGroup owner=${AvatarProtocol.hex(owner)} file=$key retry=$retry") break @@ -311,22 +295,80 @@ class AvatarRepository(context: Context) { } } - private suspend fun download(group: String, m: ChatMember, root: File): Bitmap { + private suspend fun objectFile(group: String, m: ChatMember, file: String, limit: Int) { 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(readImage(temp), 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 + val epoch=generation; val root=directory(account) + val operation=objectLoads[file] ?: scope.async { + transfers.withPermit { + val target=File(root,file) + val hit=withContext(Dispatchers.IO) { + if (!target.isFile) false else try { AvatarAssets.read(target,file,limit); true } + catch (e: Exception) { + log("WARN", "Invalid cache object=$file: ${e.message}") + if (!target.delete()) log("WARN", "Cannot delete invalid cache=$target") + false + } + } + if (!hit) { + withContext(Dispatchers.IO) { if (!root.isDirectory && !root.mkdirs()) throw IOException("cannot create avatar cache") } + 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/$file",temp.path,maxBytes=limit) + }) ?: throw IOException("missing transfer result") + require(reply.status==0 && reply.size in 1..limit) { "download status=${reply.status} size=${reply.size}" } + withContext(Dispatchers.IO) { AvatarAssets.store(root,file,AvatarAssets.read(temp,file,limit)) } + } finally { withContext(NonCancellable+Dispatchers.IO) { + if (temp.exists() && !temp.delete()) log("WARN", "Cannot delete download temp=$temp") + } } + } + alive(epoch); log("DEBUG", "Object ready file=$file cache=$hit group=$group") } - } finally { withContext(NonCancellable + Dispatchers.IO) { if (temp.exists() && !temp.delete()) log("WARN", "Cannot delete temp=$temp") } } + }.also { objectLoads[file]=it } + try { operation.await(); alive(epoch) } + finally { if (generation==epoch && operation.isCompleted && objectLoads[file]===operation) objectLoads.remove(file) } + } + + suspend fun loadAvatar(group: String, m: ChatMember, original: Boolean): Bitmap { + val epoch=generation; val root=directory(account) + if (!original) cache.get(m.avatarFile)?.let { return it } + objectFile(group,m,m.avatarFile,AvatarProtocol.MAX_MANIFEST) + val record=withContext(Dispatchers.IO) { AvatarAssets.record(root,m.avatarFile) } + objectFile(group,m,if (original) record.original else record.preview, + if (original) AvatarProtocol.MAX_ORIGINAL else AvatarProtocol.MAX_USER) + val bitmap=withContext(Dispatchers.IO) { AvatarAssets.image(root,record,original).also { prune(root) } } + alive(epoch); if (!original) { cache.put(m.avatarFile,bitmap); changed() } + return bitmap + } + + fun gallerySource(group: String?, owner: Long): Pair? { + val m=member(group,owner) ?: return null + if (m.avatarServer==0L || !AvatarProtocol.validFile(m.avatarFile)) return null + val source=group ?: _members.value.entries.firstOrNull { it.value==m }?.key?.group ?: return null + return source to m + } + + suspend fun catalog(group: String, m: ChatMember): List { + val epoch=generation; var restarts=0 + while (true) { + var cursor=0L; var version=ByteArray(16); val entries=linkedMapOf(); var restart=false + do { + val reply=AvatarProtocol.response(request(49,group,m.avatarServer,m.nodeId,1) { id,gid -> + NativeLib.avatarList(id,gid,m.avatarServer,m.nodeId,cursor,version) + }) + alive(epoch); require(reply.path=="avatars") { "catalog path mismatch" } + if (reply.status in listOf(6,7) && restarts++<3) { restart=true; delay(1000); break } + require(reply.status==0) { "catalog status=${reply.status}" } + AvatarProtocol.entries(reply).forEach { entries[it.file]=it.mtime } + if (reply.more) require(reply.cursor>cursor) { "catalog cursor did not advance" } + cursor=reply.cursor; version=reply.version + } while (reply.more) + if (restart) continue + val files=entries.keys.sortedWith(compareByDescending { entries[it] }.thenBy { it }).toMutableList() + if (m.avatarFile !in files) files.add(0,m.avatarFile) + log("INFO", "Catalog ready group=$group owner=${AvatarProtocol.hex(m.nodeId)} count=${files.size}") + return files + } } private fun readImage(file: File): ByteArray {