Browse Source

android: add asynchronous avatar JNI operations and wire models

master
evgeny 1 week ago
parent
commit
8efe075679
  1. 62
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarProtocol.kt
  2. 10
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  3. 15
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  4. 109
      tools/chatgui-android/jni_bridge/android_jni_bridge.c

62
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())
}
}

10
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,

15
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

109
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__ */

Loading…
Cancel
Save