From 22e3c1b0a5eae5e5041848c438e7ba9e64af8f62 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 8 Oct 2026 10:29:29 +0200 Subject: [PATCH] android: model channel reply drafts and native message references --- .../java/com/utun/chat/data/ChannelReply.kt | 12 +++++++ .../java/com/utun/chat/data/ChatRepository.kt | 7 ++-- .../main/java/com/utun/chat/data/NativeLib.kt | 35 +++++++++++-------- 3 files changed, 37 insertions(+), 17 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChannelReply.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChannelReply.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChannelReply.kt new file mode 100644 index 00000000..3c4c78d2 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChannelReply.kt @@ -0,0 +1,12 @@ +package com.utun.chat.data + +/** Снимок выбранного ответа: revision отличает повторный выбор того же сообщения. */ +data class ChannelReply(val channelId: String, val message: Message, val revision: Long) + +fun messageIdBytes(id: String): ByteArray { + if (id.isEmpty()) return byteArrayOf() + require(id.length == 64 && id.all { it in '0'..'9' || it in 'a'..'f' }) { "Invalid message ID" } + return ByteArray(32) { id.substring(it * 2, it * 2 + 2).toInt(16).toByte() } +} + +fun validMessageId(id: String): Boolean = id.length == 64 && id.all { it in '0'..'9' || it in 'a'..'f' } && id.any { it != '0' } 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 035dba86..25e7612e 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 @@ -68,7 +68,7 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L 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 authorNodeId: Long = 0, - val messageId: String = "", val feedback: FeedbackState? = null) + val messageId: String = "", val feedback: FeedbackState? = null, val replyTo: String = "") data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false, val isAdmin: Boolean = false, val isSuper: Boolean = false, @@ -290,10 +290,10 @@ class ChatRepository { return 0 } - fun getMessages(channelId: String, limit: Int = 50): List { + fun getMessages(channelId: String, limit: Int = 50, anchor: String = "", exact: Boolean = false): List { val list = mutableListOf() try { - val arr = JSONArray(NativeLib.getMessages(channelId, limit)) + val arr = JSONArray(NativeLib.getMessages(channelId, limit, anchor, exact)) for (i in 0 until arr.length()) { val obj = arr.getJSONObject(i) val text = obj.optString("text", "") @@ -385,6 +385,7 @@ class ChatRepository { list.add(Message( id = obj.getLong("id"), messageId = obj.optString("messageId"), + replyTo = obj.optString("replyTo").takeIf(::validMessageId).orEmpty(), author = obj.getString("author"), authorNodeId = obj.optLong("authorNodeId"), text = displayText, 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 b7a4226f..25c51be0 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 @@ -84,7 +84,8 @@ object NativeLib { /* ── External network activity (ConnectivityManager) ── */ fun notifyNetworkActive() { nativeNotifyNetworkActive() } - fun sendMessage(channelId: String, text: String) { nativeSendMessage(channelId, text) } + fun sendMessage(channelId: String, text: String, replyTo: String = "", requestId: Long = 0): Boolean = + nativeSendMessage(channelId, text.toByteArray(Charsets.UTF_8), messageIdBytes(replyTo), requestId) fun createChannel(name: String, channelId: String) { nativeCreateChannel(name, channelId) } fun connectNode(address: String, port: Int, pubkeyHex: String) { nativeConnectNode(address, port, pubkeyHex) } fun joinChannel(channelId: Long, nodeId: Long, joinKey: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) { @@ -96,7 +97,8 @@ object NativeLib { fun connectChannel(channelId: String) { nativeConnectChannel(channelId) } fun connectMemberNode(channelId: String, nodeId: Long) { nativeConnectMemberNode(channelId, nodeId) } fun getChannels(): String = nativeGetChannels() ?: "[]" - fun getMessages(channelId: String, limit: Int = 50): String = nativeGetMessages(channelId, limit) ?: "[]" + fun getMessages(channelId: String, limit: Int = 50, anchor: String = "", exact: Boolean = false): String = + nativeGetMessages(channelId, limit, messageIdBytes(anchor), exact) ?: "[]" fun feedbackCommand(json: String, requestId: Long = 0): Boolean = nativeFeedbackCommand(json.toByteArray(Charsets.UTF_8), requestId) fun clearDatabase(): Boolean = nativeClearDatabase() fun setDebugLevel(category: String, level: String) { nativeSetDebugLevel(category, level) } @@ -151,7 +153,8 @@ object NativeLib { fun getLocalSockets(): String = nativeGetLocalSockets() ?: "[]" /* ── Voice recording ── */ - fun voiceStart(channelId: String): Boolean = nativeVoiceStart(channelId) + fun voiceStart(channelId: String, replyTo: String = "", requestId: Long = 0): Boolean = + nativeVoiceStart(channelId, messageIdBytes(replyTo), requestId) fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples) fun voiceStop(): Int = nativeVoiceStop() fun voiceCancel() { nativeVoiceCancel() } @@ -167,16 +170,18 @@ object NativeLib { } /* ── Attachment ── */ - fun attachmentSend(channelId: String, filePath: String): Boolean = nativeAttachmentSend(channelId, filePath) + fun attachmentSend(channelId: String, filePath: String, replyTo: String = "", requestId: Long = 0): Boolean = + nativeAttachmentSend(channelId, filePath, messageIdBytes(replyTo), requestId) fun attachmentDownload(channelId: String, msgId: Long): Boolean = nativeAttachmentDownload(channelId, msgId) /* ── Video message ── */ - fun videoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int): Boolean = - nativeVideoSend(channelId, filePath, durationMs, width, height) + fun videoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int, + replyTo: String = "", requestId: Long = 0): Boolean = + nativeVideoSend(channelId, filePath, durationMs, width, height, messageIdBytes(replyTo), requestId) /* ── Photo message ── */ - fun photoSend(channelId: String, filePath: String, width: Int, height: Int): Boolean = - nativePhotoSend(channelId, filePath, width, height) + fun photoSend(channelId: String, filePath: String, width: Int, height: Int, replyTo: String = "", requestId: Long = 0): Boolean = + nativePhotoSend(channelId, filePath, width, height, messageIdBytes(replyTo), requestId) /* ── Voice playback ── */ fun voiceDecodeOpen(filePath: String, outInfo: IntArray): Long = nativeVoiceDecodeOpen(filePath, outInfo) @@ -230,7 +235,7 @@ object NativeLib { private external fun nativeStop() private external fun nativeSetEventCallback(cb: Any) private external fun nativeDestroy() - private external fun nativeSendMessage(channelId: String, text: String) + private external fun nativeSendMessage(channelId: String, text: ByteArray, replyTo: ByteArray, requestId: Long): Boolean private external fun nativeCreateChannel(name: String, channelId: String) private external fun nativeConnectNode(address: String, port: Int, pubkeyHex: String) private external fun nativeJoinChannel(channelId: Long, nodeId: Long, joinKey: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) @@ -238,7 +243,7 @@ object NativeLib { private external fun nativeConnectChannel(channelId: String) private external fun nativeConnectMemberNode(channelId: String, nodeId: Long) private external fun nativeGetChannels(): String? - private external fun nativeGetMessages(channelId: String, limit: Int): String? + private external fun nativeGetMessages(channelId: String, limit: Int, anchor: ByteArray, exact: Boolean): String? private external fun nativeFeedbackCommand(json: ByteArray, requestId: Long): Boolean private external fun nativeSetDebugLevel(category: String, level: String) private external fun nativeSetChatSetting(name: String, value: String) @@ -279,7 +284,7 @@ object NativeLib { private external fun nativeGetLocalSockets(): String? /* ── Voice recording JNI ── */ - private external fun nativeVoiceStart(channelId: String): Boolean + private external fun nativeVoiceStart(channelId: String, replyTo: ByteArray, requestId: Long): Boolean private external fun nativeVoiceFeed(samples: ShortArray): Boolean private external fun nativeVoiceStop(): Int private external fun nativeVoiceCancel() @@ -295,14 +300,16 @@ object NativeLib { ) /* ── Attachment JNI ── */ - private external fun nativeAttachmentSend(channelId: String, filePath: String): Boolean + private external fun nativeAttachmentSend(channelId: String, filePath: String, replyTo: ByteArray, requestId: Long): Boolean private external fun nativeAttachmentDownload(channelId: String, msgId: Long): Boolean /* ── Video message JNI ── */ - private external fun nativeVideoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int): Boolean + private external fun nativeVideoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int, + replyTo: ByteArray, requestId: Long): Boolean /* ── Photo message JNI ── */ - private external fun nativePhotoSend(channelId: String, filePath: String, width: Int, height: Int): Boolean + private external fun nativePhotoSend(channelId: String, filePath: String, width: Int, height: Int, + replyTo: ByteArray, requestId: Long): Boolean /* ── Voice playback JNI ── */ private external fun nativeVoiceDecodeOpen(filePath: String, outInfo: IntArray): Long