|
|
|
|
@ -64,7 +64,7 @@ class AvatarRepository(context: Context) {
|
|
|
|
|
scope.launch { |
|
|
|
|
try { |
|
|
|
|
val bitmap = withContext(Dispatchers.IO) { |
|
|
|
|
decode(File(directory(uid), file).readBytes(), file.removeSuffix(".png"), false) |
|
|
|
|
decode(readImage(File(directory(uid), file)), file.removeSuffix(".png"), false) |
|
|
|
|
} |
|
|
|
|
alive(epoch); cache.put(file, bitmap); changed() |
|
|
|
|
} catch (e: CancellationException) { throw e } |
|
|
|
|
@ -148,7 +148,7 @@ class AvatarRepository(context: Context) {
|
|
|
|
|
_members.value = _members.value.filterKeys { it.group != group } |
|
|
|
|
_groups.value -= group; dirtyGroups.remove(group); changed(); return |
|
|
|
|
} |
|
|
|
|
if (_groups.value.containsKey(group)) ensureGroup(group, true) |
|
|
|
|
if (type == 4 && _groups.value.containsKey(group)) ensureGroup(group, true) |
|
|
|
|
requestMembers(group) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
@ -222,7 +222,7 @@ class AvatarRepository(context: Context) {
|
|
|
|
|
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) { |
|
|
|
|
val bitmap = if (record.image.isEmpty()) null else cache.get(groupKey(group, state)) ?: withContext(Dispatchers.IO) { |
|
|
|
|
decode(record.image, record.hash, true) |
|
|
|
|
} |
|
|
|
|
alive(epoch) |
|
|
|
|
@ -259,28 +259,47 @@ class AvatarRepository(context: Context) {
|
|
|
|
|
while (true) { |
|
|
|
|
alive(epoch) |
|
|
|
|
foreground.first { it } |
|
|
|
|
if (member(group, owner)?.avatarFile != key) break |
|
|
|
|
// Повтор берёт актуальную ссылку; тот же хеш может быть доступен через другую группу/сервер. |
|
|
|
|
val candidates = _members.value.entries.filter { it.value.avatarFile == key && it.value.avatarServer != 0L } |
|
|
|
|
.sortedBy { if (it.key.group == sourceGroup && it.key.owner == owner) 0 else 1 } |
|
|
|
|
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(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 } |
|
|
|
|
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(sourceGroup, m, root) |
|
|
|
|
} ?: download(source.key.group, source.value, 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}") |
|
|
|
|
log("WARN", "User image failed group=${source.key.group} owner=${AvatarProtocol.hex(source.value.nodeId)} server=${AvatarProtocol.hex(source.value.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) } |
|
|
|
|
} finally { |
|
|
|
|
if (generation == epoch) { |
|
|
|
|
loads.remove(key) |
|
|
|
|
if (cache.get(key) == null) changed() |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -293,7 +312,7 @@ class AvatarRepository(context: Context) {
|
|
|
|
|
}) ?: 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 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) |
|
|
|
|
@ -302,6 +321,21 @@ class AvatarRepository(context: Context) {
|
|
|
|
|
} finally { withContext(NonCancellable + Dispatchers.IO) { if (temp.exists() && !temp.delete()) log("WARN", "Cannot delete temp=$temp") } } |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
private fun readImage(file: File): ByteArray { |
|
|
|
|
val size = file.length() |
|
|
|
|
require(size in 1..AvatarProtocol.MAX_USER) { "invalid cached file size=$size path=$file" } |
|
|
|
|
return file.inputStream().use { input -> |
|
|
|
|
val bytes = ByteArray(size.toInt()); var offset = 0 |
|
|
|
|
while (offset < bytes.size) { |
|
|
|
|
val count = input.read(bytes, offset, bytes.size - offset) |
|
|
|
|
require(count > 0) { "cached file truncated while reading" } |
|
|
|
|
offset += count |
|
|
|
|
} |
|
|
|
|
require(input.read() == -1) { "cached file grew while reading" } |
|
|
|
|
bytes |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/** Проверяем размер, хеш и геометрию до выделения декодированного изображения. */ |
|
|
|
|
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" } |
|
|
|
|
@ -317,7 +351,9 @@ class AvatarRepository(context: Context) {
|
|
|
|
|
} |
|
|
|
|
private fun prune(root: File) { |
|
|
|
|
val pinned = defaultAvatar?.substringAfter('/') |
|
|
|
|
val files = root.listFiles()?.filter { AvatarProtocol.validFile(it.name) }?.sortedBy { it.lastModified() } ?: return |
|
|
|
|
val entries = root.listFiles() |
|
|
|
|
if (entries == null) { log("WARN", "Cannot enumerate avatar cache directory=$root"); return } |
|
|
|
|
val files = entries.filter { AvatarProtocol.validFile(it.name) }.sortedBy { it.lastModified() } |
|
|
|
|
var size = files.sumOf { it.length() } |
|
|
|
|
for (file in files) if (size > 100L * 1024 * 1024 && file.name != pinned) { |
|
|
|
|
val bytes = file.length() |
|
|
|
|
@ -401,7 +437,7 @@ class AvatarRepository(context: Context) {
|
|
|
|
|
transfers.withPermit { |
|
|
|
|
if (server != 0L) { |
|
|
|
|
val local = File(directory(uid), file) |
|
|
|
|
withContext(Dispatchers.IO) { decode(local.readBytes(), file.removeSuffix(".png"), false) } |
|
|
|
|
withContext(Dispatchers.IO) { decode(readImage(local), 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") }) |
|
|
|
|
|