Browse Source

Load Android gallery objects and catalogs through the shared cache

master
evgeny 5 days ago
parent
commit
ea6b895df9
  1. 110
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarRepository.kt

110
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<Map<String, AvatarEditState>>(emptyMap())
val edits: StateFlow<Map<String, AvatarEditState>> = _edits
private val loads = mutableSetOf<String>()
private val objectLoads = mutableMapOf<String, Deferred<Unit>>()
private val memberRequests = mutableMapOf<String, Long>()
private val editTokens = mutableMapOf<String, Long>()
private val replications = mutableSetOf<String>()
@ -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<String,ChatMember>? {
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<String> {
val epoch=generation; var restarts=0
while (true) {
var cursor=0L; var version=ByteArray(16); val entries=linkedMapOf<String,Long>(); 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<String> { 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 {

Loading…
Cancel
Save