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 new file mode 100644 index 00000000..4913e72c --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarProtocol.kt @@ -0,0 +1,62 @@ +package com.utun.chat.data + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** Форматы событий общего ядра; все идентификаторы сохраняют 64 бита, включая старший бит. */ +object AvatarProtocol { + const val MAX_USER = 1024 * 1024 + const val MAX_GROUP = 16 * 1024 + private val filePattern = Regex("[0-9a-f]{64}\\.png") + fun validFile(file: String): Boolean = filePattern.matches(file) + fun node(id: String): Long = id.toULongOrNull(16)?.toLong() ?: 0 + fun hex(id: Long): String = id.toULong().toString(16).padStart(16, '0') + fun hash(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it.toInt() and 255) } + private fun buffer(data: ByteArray) = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + + data class Group(val id: Long, val group: Long, val version: Long, val hash: String, + val canEdit: Boolean, val failed: Boolean, val image: ByteArray) + fun group(data: ByteArray): Group { + require(data.size in 60..60 + MAX_GROUP) { "group snapshot length=${data.size}" } + val b = buffer(data) + val id = b.long; val group = b.long; val version = b.long + val hash = ByteArray(32); b.get(hash) + val canEdit = b.get().toInt(); val failed = b.get().toInt() + val size = b.short.toInt() and 65535 + require(canEdit in 0..1 && failed in 0..1 && size == b.remaining()) { "invalid group snapshot header" } + return Group(id, group, version, hash(hash), canEdit != 0, failed != 0, ByteArray(size).also { b.get(it) }) + } + + 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 = "") + fun response(data: ByteArray): Reply { + require(data.size >= 141) { "file response length=${data.size}" } + val b = buffer(data) + val group = b.long; val server = b.long + require(b.get().toInt() == 4 && b.get().toInt() == 2) { "invalid file response protocol/kind" } + 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 + b.position(16 + 77); val hash = ByteArray(32); b.get(hash) + 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)) + } + fun transfer(data: ByteArray): Reply? { + require(data.size == 51) { "transfer length=${data.size}" } + val b = buffer(data) + val id = b.long; val group = b.long; val server = b.long; val owner = b.long + val done = b.long; val total = b.long; val status = b.short.toInt() and 65535 + val state = b.get().toInt() + require(state in 1..2 && done >= 0 && total >= 0 && done <= total) { "invalid transfer progress" } + return if (state == 2) Reply(id, group, server, owner, status = status, size = total) else null + } + fun published(data: ByteArray): Reply { + require(data.size == 17 && data[16].toInt() in 0..1) { "invalid publication result" } + val b = buffer(data) + return Reply(b.long, b.long, status = b.get().toInt()) + } +} 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 643a5d6d..0b09b441 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 @@ -67,7 +67,7 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L val imageWidth: Int = 0, val imageHeight: Int = 0, val videoWidth: Int = 0, val videoHeight: Int = 0, val videoDurationMs: Int = 0, val downloadState: String = "", /* "", "downloading", "downloaded", "error" */ - val deliveryStatusRes: Int = 0, val inviteGroupName: String? = null) + val deliveryStatusRes: Int = 0, val inviteGroupName: String? = null, val authorNodeId: Long = 0) data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false, val isAdmin: Boolean = false, val isSuper: Boolean = false, @@ -79,7 +79,8 @@ data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = val directUp: Boolean = false, val indirectUp: Boolean = false, val directPresence: Boolean = false, val indirectPresence: Boolean = false, val bgpPresence: Boolean = false, val bgpRtt: Int = 0, - val bgpNodes: Int = 0, val connecting: Boolean = false) { + val bgpNodes: Int = 0, val connecting: Boolean = false, + val avatarServer: Long = 0, val avatarFile: String = "", val storageRole: Boolean = false) { val flags: Int get() = (if (isSuper) 1 else 0) or (if (isAdmin) 2 else 0) or (if (isModer) 4 else 0) } @@ -195,7 +196,8 @@ fun MemberWire.toChatMember(previous: ChatMember = ChatMember(nodeId, name)): Ch isAdmin = (flags and 2) != 0, isSuper = (flags and 1) != 0, isModer = (flags and 4) != 0, isStorage = storage, isDeleted = (flags and 8) != 0, radioActive = (flags and 0x10) != 0, - rtt = if (rtt == 0xFFFF) 0 else rtt, isSelf = isSelf + rtt = if (rtt == 0xFFFF) 0 else rtt, isSelf = isSelf, + avatarServer = avatarServer, avatarFile = avatarFile, storageRole = storageRole ) fun cleanSocketName(raw: String): String = @@ -381,6 +383,7 @@ class ChatRepository { list.add(Message( id = obj.getLong("id"), author = obj.getString("author"), + authorNodeId = obj.optLong("authorNodeId"), text = displayText, ts = obj.getLong("ts"), isOutgoing = obj.optBoolean("isOutgoing", false), @@ -597,6 +600,7 @@ class ChatRepository { list.add(Message( id = obj.optLong("seq", 0), author = author, + authorNodeId = obj.optLong("author", 0), text = obj.optString("data", ""), ts = obj.optLong("ts", 0), isOutgoing = outgoing, 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 4152ff11..3e708eb0 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 @@ -117,6 +117,19 @@ object NativeLib { fun getChannelMembers(channelId: String): String = nativeGetChannelMembers(channelId) ?: "[]" /** Асинхронный live-снимок; ответ — событие MEMBER_LIST. */ 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) + 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 = + nativeAvatarOperation(4, id, group, server, owner, remote, null, null) + fun setMemberAvatar(id: Long, group: Long, server: Long, file: String): Boolean = + nativeAvatarOperation(5, id, group, server, 0, file, null, null) + fun setGroupAvatar(id: Long, group: Long, data: ByteArray): Boolean = + nativeAvatarOperation(6, id, group, 0, 0, null, null, data) + fun cancelAvatar(id: Long): Boolean = nativeAvatarOperation(7, id, 0, 0, 0, null, null, null) fun getMemberDetail(channelId: String, nodeId: Long): String = nativeGetMemberDetail(channelId, nodeId) ?: "{}" fun setMemberNick(channelId: String, nodeId: Long, nick: String) { nativeSetMemberNick(channelId, nodeId, nick) } fun isChannelOwner(channelId: String): Boolean = nativeIsChannelOwner(channelId) @@ -243,6 +256,8 @@ object NativeLib { /* ── Channel members JNI ── */ private external fun nativeGetChannelMembers(channelId: String): String? private external fun nativeRequestMemberList(channelId: String): Boolean + private external fun nativeAvatarOperation(op: Int, id: Long, group: Long, server: Long, owner: Long, + remote: String?, local: String?, data: ByteArray?): Boolean private external fun nativeGetMemberDetail(channelId: String, nodeId: Long): String? private external fun nativeSetMemberNick(channelId: String, nodeId: Long, nick: String) private external fun nativeIsChannelOwner(channelId: String): Boolean diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 3edfe4ad..4e85289d 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -28,6 +28,8 @@ #include "../../../lib/sqlite3.h" #include "../../../src/chat/chat_core.h" #include "../../../src/chat/chat_member.h" +#include "../../../src/chat/group_avatar.h" +#include "../../../src/member_files/member_files.h" #include "../../../src/chat/invite_build.h" #include "../../../src/chat/chat_setting.h" #include "../../../src/routing_layer/topo_node_sqlite.h" @@ -576,14 +578,14 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit) { char* esc_fp = file_path[0] ? json_escape_alloc(file_path) : NULL; char* esc_la = lattr && lattr[0] ? json_escape_alloc(lattr) : NULL; - size_t needed = snprintf(NULL, 0, "%s{\"id\":%lld,\"author\":\"%s\",\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s,\"contentType\":\"%s\"%s%s%s%s%s%s}", - sep, (long long)msg_id, author_name, esc_txt, (long long)ts, is_out ? "true" : "false", esc_ct, + size_t needed = snprintf(NULL, 0, "%s{\"id\":%lld,\"author\":\"%s\",\"authorNodeId\":%lld,\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s,\"contentType\":\"%s\"%s%s%s%s%s%s}", + sep, (long long)msg_id, author_name, (long long)node_id, esc_txt, (long long)ts, is_out ? "true" : "false", esc_ct, esc_fp ? ",\"filePath\":\"" : "", esc_fp ? esc_fp : "", esc_fp ? "\"" : "", esc_la ? ",\"localAttrs\":\"" : "", esc_la ? esc_la : "", esc_la ? "\"" : ""); while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; } if (pos + needed + 2 <= cap) - pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":%lld,\"author\":\"%s\",\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s,\"contentType\":\"%s\"%s%s%s%s%s%s}", - sep, (long long)msg_id, author_name, esc_txt, (long long)ts, is_out ? "true" : "false", esc_ct, + pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":%lld,\"author\":\"%s\",\"authorNodeId\":%lld,\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s,\"contentType\":\"%s\"%s%s%s%s%s%s}", + sep, (long long)msg_id, author_name, (long long)node_id, esc_txt, (long long)ts, is_out ? "true" : "false", esc_ct, esc_fp ? ",\"filePath\":\"" : "", esc_fp ? esc_fp : "", esc_fp ? "\"" : "", esc_la ? ",\"localAttrs\":\"" : "", esc_la ? esc_la : "", esc_la ? "\"" : ""); if (esc_la) u_free(esc_la); @@ -2664,4 +2666,103 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioTalkEnd( utun_bridge_radio_talk_end((uint64_t)groupId); } +/* Снимок читается в потоке ядра; локальное JNI-событие 53 не меняет сетевой протокол. + * id/group/version:8LE, hash:32, can_edit/failed:1, size:2LE, data:size. */ +static void android_avatar_snapshot(void* arg) { + struct group_avatar_gui_request* req = arg; + struct group_avatar_record record; + memset(&record, 0, sizeof(record)); + int failed = group_avatar_get(req->inst, req->group, &record) < 0; + char channel[17]; uint8_t key[32]; + snprintf(channel, sizeof(channel), "%016llx", (unsigned long long)req->group); + int can_edit = topo_node_sqlite_channel_get_priv(chat_core_get_db(req->inst), channel, key) == 0; + memset(key, 0, sizeof(key)); + if (failed) DEBUG_WARN(DEBUG_CATEGORY_CHAT, "Android avatar: snapshot failed group=%s id=%llu", channel, + (unsigned long long)req->id); + size_t size = failed ? 0 : record.size; + uint8_t* event = u_calloc(1, 60 + size); + if (!event) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "Android avatar: snapshot allocation failed id=%llu", (unsigned long long)req->id); + u_free(req); return; + } + mf_put64(event, req->id); mf_put64(event + 8, req->group); mf_put64(event + 16, record.version); + memcpy(event + 24, record.hash, 32); event[56] = can_edit; event[57] = failed; + event[58] = size & 255; event[59] = size >> 8; + if (size) memcpy(event + 60, record.data, size); + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "Android avatar: snapshot group=%s id=%llu version=%llu size=%zu edit=%d failed=%d", + channel, (unsigned long long)req->id, (unsigned long long)record.version, size, can_edit, failed); + jni_on_event(53, event, (int)(60 + size)); + u_free(event); u_free(req); +} + +/* Только операции аватарок; файловые пути ограничены каталогом avatars. Владение req переходит uasync. */ +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeAvatarOperation( + JNIEnv* env, jobject thiz, jint op, jlong id, jlong group, jlong server, jlong owner, + jstring remote, jstring local, jbyteArray data) { + (void)thiz; + struct UTUN_INSTANCE* inst = instance_lite_get_instance(); + struct UASYNC* ua = instance_lite_get_uasync(); + if (!inst || !ua || !uasync_is_running(ua) || !id || (!group && op != 7) || op < 0 || op > 7) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "Android avatar: unavailable/invalid operation=%d id=%lld group=%llx", op, + (long long)id, (unsigned long long)group); return JNI_FALSE; + } + const char* path = remote ? (*env)->GetStringUTFChars(env, remote, NULL) : NULL; + const char* file = local ? (*env)->GetStringUTFChars(env, local, NULL) : NULL; + void* req = NULL; uasync_post_callback_t callback = NULL; + if ((remote && !path) || (local && !file)) goto invalid; + if (op >= 1 && op <= 4 && (!server || !owner || !path || + (op == 3 ? strcmp(path, "avatars") != 0 : + strncmp(path, "avatars/", 8) != 0 || !mf_avatar_file_valid(path + 8)))) goto invalid; + if (op == 0 || op == 6) { + struct group_avatar_gui_request* r = u_calloc(1, sizeof(*r)); req = r; + if (!r) goto invalid; + r->inst = inst; r->id = id; r->group = group; + if (op == 6) { + jsize size = data ? (*env)->GetArrayLength(env, data) : 0; + if (size > GROUP_AVATAR_MAX) goto invalid; + r->size = size; + if (size) (*env)->GetByteArrayRegion(env, data, 0, size, (jbyte*)r->data); + if ((*env)->ExceptionCheck(env)) goto invalid; + } + callback = op == 0 ? android_avatar_snapshot : group_avatar_gui_set; + } else if (op == 1 || op == 2) { + if (!file || !file[0] || strlen(file) > MF_PATH_MAX) goto invalid; + struct mf_transfer_request* r = u_calloc(1, sizeof(*r)); req = r; + if (!r) goto invalid; + r->inst = inst; r->id = id; r->group = group; r->server = server; r->owner = owner; + r->upload = op == 2; r->max_bytes = MF_AVATAR_MAX_BYTES; + memcpy(r->remote, path, strlen(path) + 1); memcpy(r->local, file, strlen(file) + 1); + callback = member_files_gui_transfer; + } else if (op == 3 || op == 4) { + struct mf_gui_request* r = u_calloc(1, sizeof(*r)); req = r; + if (!r) goto invalid; + r->inst = inst; r->group = group; r->server = server; + r->message.kind = MF_REQUEST; r->message.command = op == 3 ? MF_MKDIR : MF_STAT; + r->message.request_id = id; r->message.owner = owner; + memcpy(r->message.path, path, strlen(path) + 1); callback = member_files_gui_request; + } else if (op == 5) { + if (server && (!path || !mf_avatar_file_valid(path))) goto invalid; + struct chat_avatar_req* r = u_calloc(1, sizeof(*r)); req = r; + if (!r) goto invalid; + r->inst = inst; r->id = id; r->group = group; r->server = server; + if (server) memcpy(r->file, path, strlen(path) + 1); + callback = chat_core_set_my_avatar_trampoline; + } else { + struct mf_cancel_request* r = u_calloc(1, sizeof(*r)); req = r; + if (!r) goto invalid; + r->inst = inst; r->id = id; callback = member_files_gui_cancel; + } + if (path) (*env)->ReleaseStringUTFChars(env, remote, path); + if (file) (*env)->ReleaseStringUTFChars(env, local, file); + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "Android avatar: queued operation=%d id=%lld group=%016llx server=%016llx owner=%016llx", + op, (long long)id, (unsigned long long)group, (unsigned long long)server, (unsigned long long)owner); + uasync_post(ua, callback, req); return JNI_TRUE; +invalid: + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "Android avatar: invalid arguments/allocation operation=%d id=%lld group=%016llx", + op, (long long)id, (unsigned long long)group); + if (path) (*env)->ReleaseStringUTFChars(env, remote, path); + if (file) (*env)->ReleaseStringUTFChars(env, local, file); + u_free(req); return JNI_FALSE; +} + #endif /* __ANDROID__ */