From 07aa436380a8571af64dbd953617dce9ff09cf92 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 19:37:45 +0200 Subject: [PATCH] Decode avatar manifests and paginated catalogs on Android --- .../java/com/utun/chat/data/AvatarProtocol.kt | 30 +++++++++++++++++-- .../java/com/utun/chat/data/ChatRepository.kt | 4 +-- .../main/java/com/utun/chat/data/NativeLib.kt | 8 +++-- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarProtocol.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarProtocol.kt index 94bdaa0c..1736e53b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarProtocol.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarProtocol.kt @@ -7,8 +7,12 @@ import java.nio.ByteOrder object AvatarProtocol { const val MAX_USER = 1024 * 1024 const val MAX_GROUP = 16 * 1024 - private val filePattern = Regex("[0-9a-f]{64}\\.png") + const val MAX_ORIGINAL = 32 * 1024 * 1024 + const val MAX_MANIFEST = 4096 + private val filePattern = Regex("[0-9a-f]{64}\\.json") + private val objectPattern = Regex("[0-9a-f]{64}\\.(png|jpg|webp|json)") fun validFile(file: String): Boolean = filePattern.matches(file) + fun validObject(file: String): Boolean = objectPattern.matches(file) // channel_id в БД/событиях — десятичный u64; avatar_server в userinfo — hex. fun groupId(id: String): Long = id.toULongOrNull(10)?.toLong() ?: 0 fun node(id: String): Long = id.toULongOrNull(16)?.toLong() ?: 0 @@ -30,7 +34,9 @@ object AvatarProtocol { } data class Reply(val id: Long, val group: Long, val server: Long = 0, val owner: Long = 0, - val command: Int = 0, val status: Int = 0, val size: Long = 0, val hash: String = "", val fileType: Int = 0) + val command: Int = 0, val status: Int = 0, val size: Long = 0, val hash: String = "", val fileType: Int = 0, + val cursor: Long = 0, val version: ByteArray = byteArrayOf(), val more: Boolean = false, + val path: String = "", val payload: ByteArray = byteArrayOf()) fun response(data: ByteArray): Reply { require(data.size >= 141) { "file response length=${data.size}" } val b = buffer(data) @@ -39,14 +45,32 @@ object AvatarProtocol { val command = b.get().toInt() and 255; val status = b.short.toInt() and 65535 val id = b.long; val owner = b.long b.long; val size = b.long + val cursor = b.long; val version = ByteArray(16).also { b.get(it) } b.position(16 + 77); val hash = ByteArray(32); b.get(hash) b.position(16 + 117); val fileType = b.get().toInt() and 255 + val more = b.get().toInt() and 255; require(more in 0..1) { "invalid list more flag" } b.position(16 + 119) val path = b.short.toInt() and 65535; val target = b.short.toInt() and 65535; val payload = b.short.toInt() and 65535 require(path <= 1024 && target <= 1024 && payload <= 8192 && b.remaining() == path + target + payload) { "invalid file response body lengths" } - return Reply(id, group, server, owner, command, status, size, hash(hash), fileType) + val remote = ByteArray(path).also { b.get(it) }; b.position(b.position() + target) + val body = ByteArray(payload).also { b.get(it) } + return Reply(id, group, server, owner, command, status, size, hash(hash), fileType, cursor, version, more, + String(remote, Charsets.UTF_8), body) + } + data class Entry(val file: String, val mtime: Long) + fun entries(reply: Reply): List { + val b = buffer(reply.payload); val result = mutableListOf() + while (b.hasRemaining()) { + require(b.remaining() >= 35) { "truncated list entry" } + val length = b.short.toInt() and 65535; val type = b.get().toInt() and 255 + val size = b.long; val mtime = b.long; b.position(b.position() + 16) + require(length in 1..255 && length <= b.remaining() && type in 1..2) { "invalid list entry" } + val name = String(ByteArray(length).also { b.get(it) }, Charsets.UTF_8) + if (type == 1 && validFile(name) && size in 1..MAX_MANIFEST) result += Entry(name, mtime) + } + return result } fun transfer(data: ByteArray): Reply? { require(data.size == 51) { "transfer length=${data.size}" } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index 10910137..889a5178 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -163,7 +163,7 @@ fun parseConnList(data: ByteArray): List { } /* Размер общего снимка из chat_member.h; одинаковый для desktop и Android. */ -const val MEMBER_WIRE_SIZE = 157 +const val MEMBER_WIRE_SIZE = 158 data class MemberWire( val nodeId: Long, val online: Int, val isSelf: Boolean, val flags: Int, val rtt: Int, val natType: Int, @@ -184,7 +184,7 @@ fun parseMemberWire(data: ByteArray, offset: Int = 0): MemberWire? { val nameBytes = ByteArray(64); buf.get(nameBytes) val name = String(nameBytes).trimEnd('\u0000') val avatarServer = buf.getLong() - val avatarBytes = ByteArray(69); buf.get(avatarBytes) + val avatarBytes = ByteArray(70); buf.get(avatarBytes) val avatarFile = String(avatarBytes).trimEnd('\u0000') val storageRole = buf.get().toInt() != 0 return MemberWire(nodeId, online, isSelf, flags, rtt, natType, storage, name, avatarServer, avatarFile, storageRole) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 78418ad4..c493adff 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -119,8 +119,12 @@ object NativeLib { fun requestMemberList(channelId: String): Boolean = nativeRequestMemberList(channelId) fun requestGroupAvatar(id: Long, group: Long): Boolean = nativeAvatarOperation(0, id, group, 0, 0, null, null, null) fun transferAvatar(id: Long, group: Long, server: Long, owner: Long, remote: String, local: String, - upload: Boolean = false): Boolean = - nativeAvatarOperation(if (upload) 2 else 1, id, group, server, owner, remote, local, null) + upload: Boolean = false, maxBytes: Int = AvatarProtocol.MAX_ORIGINAL): Boolean = + nativeAvatarOperation(if (upload) 2 else 1, id, group, server, owner, remote, local, + java.nio.ByteBuffer.allocate(8).order(java.nio.ByteOrder.LITTLE_ENDIAN).putLong(maxBytes.toLong()).array()) + fun avatarList(id: Long, group: Long, server: Long, owner: Long, cursor: Long, version: ByteArray): Boolean = + nativeAvatarOperation(8, id, group, server, owner, "avatars", null, + java.nio.ByteBuffer.allocate(24).order(java.nio.ByteOrder.LITTLE_ENDIAN).putLong(cursor).put(version).array()) fun avatarMkdir(id: Long, group: Long, server: Long, owner: Long): Boolean = nativeAvatarOperation(3, id, group, server, owner, "avatars", null, null) fun avatarStat(id: Long, group: Long, server: Long, owner: Long, remote: String): Boolean =