Browse Source

android: harden avatar cache reads and refresh current download sources

master
evgeny 7 days ago
parent
commit
b3c0a2fa7b
  1. 58
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarRepository.kt
  2. 5
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  3. 5
      tools/chatgui-android/jni_bridge/android_jni_bridge.c

58
tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarRepository.kt

@ -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") })

5
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt

@ -152,7 +152,10 @@ fun ChatScreen(
}
}
)
if (properties) GroupPropertiesDialog(channel, onDismiss = { properties = false })
if (properties) GroupPropertiesDialog(channel, onDismiss = {
properties = false
if (soundOn == com.utun.chat.data.NotificationSound.isMuted(channel.id)) viewModel.toggleSound()
})
if (radioActive) {
RadioSourceBar(viewModel)
}

5
tools/chatgui-android/jni_bridge/android_jni_bridge.c

@ -778,6 +778,11 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) {
char* esc_name = json_escape_alloc(local_nick && local_nick[0] ? local_nick :
(name && name[0] ? name : ""));
char* esc_userinfo = json_escape_alloc((const char*)sqlite3_column_text(st, 7));
if (!esc_name || !esc_userinfo) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "Android members: JSON allocation failed channel=%s node=%016llx",
channel_id, (unsigned long long)node_id);
u_free(esc_name); u_free(esc_userinfo); continue;
}
const char* sep = first ? "" : ",";
first = 0;

Loading…
Cancel
Save