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 3b3a4241..07ff10ab 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 @@ -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") }) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 9084c0c2..5fdd693a 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/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) } diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 2a5fa5a8..26a05699 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/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;