|
|
|
|
@ -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 { |
|
|
|
|
|