From 0a615913ebfd572930dd99c8ac4ecfc7d5b057f1 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:45:59 +0200 Subject: [PATCH] Drive Android feedback commands and live updates through the core thread --- .../com/utun/chat/viewmodel/ChatViewModel.kt | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 77f2f5db..6a597ead 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -21,6 +21,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream +import org.json.JSONObject +import org.json.JSONArray private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp") @@ -111,6 +113,12 @@ class ChatViewModel : ViewModel() { private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages + private val feedbackCache = mutableMapOf() + private val _reactionCatalog = MutableStateFlow>(emptyList()) + val reactionCatalog: StateFlow> = _reactionCatalog + private val _interactionFailed = MutableStateFlow(false) + val interactionFailed: StateFlow = _interactionFailed + private var nextInteractionId = 0L private val _currentChannel = MutableStateFlow(null) val currentChannel: StateFlow = _currentChannel @@ -284,6 +292,35 @@ class ChatViewModel : ViewModel() { _dmConversations.value = _dmConversations.value.map { it.copy(online = false) } } else if (type in listOf(4, 5, 12, 16, 17, 24, 25, 31, 33)) refreshDmCallRoute() when (type) { + 53 -> { /* FEEDBACK_CHANGED: channel, message_id, kind. */ + if (data == null || data.size < 34) return + val size = data[0].toInt() and 255 + if (data.size != size + 34) { LogManager.addLog("ERROR", "POLL", "invalid change event"); return } + val channel = String(data, 1, size, Charsets.UTF_8) + if (_currentChannel.value?.id != channel) return + val id = data.copyOfRange(size + 1, size + 33).joinToString("") { "%02x".format(it.toInt() and 255) } + sendInteraction(JSONObject().put("cmd", "feedback").put("ch", channel).put("message_id", id), false) + } + 54 -> { /* FEEDBACK_RESULT: request_id:8LE, success, UTF-8 JSON. */ + if (data == null || data.size < 9) return + try { + val id = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).long + val result = JSONObject(String(data, 9, data.size - 9, Charsets.UTF_8)) + if (data[8].toInt() == 0) { + LogManager.addLog("ERROR", "POLL", "command failed: ${result.optString("error")}") + if (id != 0L) _interactionFailed.value = true + } else if (result.has("catalog")) { + val values = result.getJSONArray("catalog") + _reactionCatalog.value = (0 until values.length()).map { index -> values.getJSONObject(index).let { + ReactionState(it.getInt("id"), it.getString("emoji")) + } } + } else if (result.optString("ch") == _currentChannel.value?.id) { + val state = parseFeedback(result) + feedbackCache[state.messageId] = state + _messages.update { rows -> rows.map { if (it.messageId == state.messageId) it.copy(feedback = state) else it } } + } + } catch (e: Exception) { LogManager.addLog("ERROR", "POLL", "invalid result: ${e.message}"); _interactionFailed.value = true } + } 9 -> { /* DB_READY */ if (dbReady) return repo = ChatRepository() @@ -937,13 +974,45 @@ class ChatViewModel : ViewModel() { fun refreshMessages(chId: String) { val r = repo ?: return viewModelScope.launch { - _messages.value = withContext(Dispatchers.IO) { r.getMessages(chId) } + val rows = withContext(Dispatchers.IO) { r.getMessages(chId) } + if (_currentChannel.value?.id != chId) return@launch + _messages.value = rows.map { it.copy(feedback = feedbackCache[it.messageId]) } + if (_reactionCatalog.value.isEmpty()) sendInteraction(JSONObject().put("cmd", "reaction_catalog"), false) + for (row in rows) if (row.messageId.length == 64) + sendInteraction(JSONObject().put("cmd", "feedback").put("ch", chId).put("message_id", row.messageId), false) + } + } + + private fun sendInteraction(command: JSONObject, userAction: Boolean = true) { + if (!command.has("ch")) command.put("ch", _currentChannel.value?.id ?: return) + if (!NativeLib.feedbackCommand(command.toString(), if (userAction) ++nextInteractionId else 0)) { + LogManager.addLog("ERROR", "POLL", "cannot queue command ${command.optString("cmd")}") + if (userAction) _interactionFailed.value = true } } + fun createPoll(question: String, options: List, multiple: Boolean, custom: Boolean) { + sendInteraction(JSONObject().put("cmd", "poll_create").put("question", question) + .put("options", JSONArray(options)).put("mode", if (multiple) "multiple" else "single").put("allow_custom_answer", custom)) + } + + fun vote(message: Message, choices: List, custom: String) { + sendInteraction(JSONObject().put("cmd", "vote").put("message_id", message.messageId) + .put("choices", JSONArray(choices)).put("custom", custom)) + } + + fun react(message: Message, emoji: Int?) { + val command = JSONObject().put("cmd", "react").put("message_id", message.messageId) + if (emoji == null) command.put("clear", true) else command.put("emoji", emoji) + sendInteraction(command) + } + + fun dismissInteractionError() { _interactionFailed.value = false } + fun selectChannel(channel: Channel) { val r = repo ?: return if (_currentChannel.value?.id != channel.id) { + feedbackCache.clear() memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null } _currentChannel.value = channel