From 317f362a7ad563ded7aa51401dcdde2314b6a36d Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 28 Oct 2026 11:26:57 +0100 Subject: [PATCH] Reproduce malformed feedback event handling in the UI --- .../main/java/com/utun/chat/data/Feedback.kt | 20 +++++ .../com/utun/chat/viewmodel/ChatViewModel.kt | 28 +++---- .../com/utun/chat/data/FeedbackEventTest.kt | 73 +++++++++++++++++++ 3 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 tools/chatgui-android/app/src/test/java/com/utun/chat/data/FeedbackEventTest.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/Feedback.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/Feedback.kt index dd9e5b59..a7ccc11d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/Feedback.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/Feedback.kt @@ -2,6 +2,8 @@ package com.utun.chat.data import org.json.JSONArray import org.json.JSONObject +import java.nio.ByteBuffer +import java.nio.ByteOrder /* Модели снимка chat_feedback_snapshot и подготовка форм опроса для общего командного API core. * Сетевой формат/подписи/подсчёт голосов остаются в C; здесь только JSON-разбор и проверка готового ввода UI. */ @@ -23,6 +25,24 @@ data class PollState(val question: String, val multiple: Boolean, val allowCusto * poll=null допустимо для обычного сообщения или ещё отсутствующего исходного опроса. */ data class FeedbackState(val messageId: String, val pending: Boolean, val reactions: List, val poll: PollState?) +data class FeedbackChangedEvent(val channelId: String, val messageId: String, val kind: Int) +data class FeedbackResultEvent(val requestId: Long, val success: Boolean, val json: JSONObject) + +fun parseFeedbackChangedEvent(data: ByteArray?): FeedbackChangedEvent? { + if (data == null || data.size < 34) return null + val size = data[0].toInt() and 255 + require(data.size == size + 34) { "invalid change event" } + val channel = String(data, 1, size, Charsets.UTF_8) + val id = data.copyOfRange(size + 1, size + 33).joinToString("") { "%02x".format(it.toInt() and 255) } + return FeedbackChangedEvent(channel, id, data.last().toInt() and 255) +} + +fun parseFeedbackResultEvent(data: ByteArray?): FeedbackResultEvent? { + if (data == null || data.size < 9) return null + val id = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).long + return FeedbackResultEvent(id, data[8].toInt() != 0, JSONObject(String(data, 9, data.size - 9, Charsets.UTF_8))) +} + /** Нормализованный ответ формы: отправляются только активные поля; пустой выбор/текст отменяет голос. */ data class PollVoteDraft(val choices: List, val custom: String) 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 c0d5fa7e..7b804217 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 @@ -504,22 +504,19 @@ class ChatViewModel : ViewModel() { if (data[16].toInt() == 0) _radioMuteFailed.value = true } 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) + try { + val event = parseFeedbackChangedEvent(data) ?: return + if (_currentChannel.value?.id != event.channelId) return + sendInteraction(JSONObject().put("cmd", "feedback").put("ch", event.channelId).put("message_id", event.messageId), false) + } catch (e: Exception) { LogManager.addLog("ERROR", "POLL", "invalid change event bytes=${data?.size}: ${e.message}") } } 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 + val event = parseFeedbackResultEvent(data) ?: return + val result = event.json + if (!event.success) { + LogManager.addLog("ERROR", "POLL", "command failed request=${event.requestId}: ${result.optString("error")}") + if (event.requestId != 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 { @@ -537,7 +534,10 @@ class ChatViewModel : ViewModel() { _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 } + } catch (e: Exception) { + LogManager.addLog("ERROR", "POLL", "invalid result bytes=${data?.size}: ${e.message}") + _interactionFailed.value = true + } } 9 -> { /* DB_READY */ if (dbReady) return diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/FeedbackEventTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/FeedbackEventTest.kt new file mode 100644 index 00000000..ca24b6d8 --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/FeedbackEventTest.kt @@ -0,0 +1,73 @@ +package com.utun.chat.data + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.CharacterCodingException +import org.junit.Assert.* +import org.junit.Test + +/** Настоящий wire-разбор, используемый ViewModel; формат фиксируют chat_event.h и C-производители событий. */ +class FeedbackEventTest { + private fun changed(channel: String = "123", kind: Int = 1): ByteArray { + val text = channel.toByteArray(Charsets.UTF_8) + return byteArrayOf(text.size.toByte()) + text + ByteArray(32) { it.toByte() } + byteArrayOf(kind.toByte()) + } + private fun result(id: Long = 7, flag: Int = 1, text: ByteArray = "{}".toByteArray()): ByteArray = + ByteBuffer.allocate(9 + text.size).order(ByteOrder.LITTLE_ENDIAN).putLong(id).put(flag.toByte()).put(text).array() + + @Test fun changedEventKeepsCanonicalChannelMessageAndFeedbackKind() { + val event = requireNotNull(parseFeedbackChangedEvent(changed("9223372036854775807", 2))) + assertEquals("9223372036854775807", event.channelId); assertEquals(2, event.kind) + assertEquals("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", event.messageId) + } + + @Test fun truncatedChangedEventsFailInsteadOfBeingSilentlyIgnored() { + assertThrows(IllegalArgumentException::class.java) { parseFeedbackChangedEvent(null) } + for (size in 0..34) assertThrows(IllegalArgumentException::class.java) { parseFeedbackChangedEvent(ByteArray(size)) } + val valid = changed() + assertThrows(IllegalArgumentException::class.java) { parseFeedbackChangedEvent(valid.copyOf(valid.size - 1)) } + assertThrows(IllegalArgumentException::class.java) { parseFeedbackChangedEvent(valid + byteArrayOf(0)) } + } + + @Test fun invalidChannelAndUnknownKindCannotTriggerASnapshotRequest() { + for (channel in listOf("", "0", "01", "-1", "+1", "1a", "é", "9223372036854775808", "1".repeat(20))) { + assertThrows(IllegalArgumentException::class.java) { parseFeedbackChangedEvent(changed(channel)) } + } + for (kind in listOf(0, 3, 255)) { + assertThrows(IllegalArgumentException::class.java) { parseFeedbackChangedEvent(changed(kind = kind)) } + } + } + + @Test fun resultPreservesAllRequestBitsAndUnicodeErrorText() { + val id = 0xfedcba9876543210uL.toLong() + val event = requireNotNull(parseFeedbackResultEvent(result(id, 0, "{\"error\":\"Отказ 😀\"}".toByteArray()))) + assertEquals(id, event.requestId); assertFalse(event.success); assertEquals("Отказ 😀", event.json.getString("error")) + val background = requireNotNull(parseFeedbackResultEvent(result(0))) + assertEquals(0L, background.requestId); assertTrue(background.success) + } + + @Test fun truncatedResultAndEmptyJsonFailForTheCallerToReport() { + assertThrows(IllegalArgumentException::class.java) { parseFeedbackResultEvent(null) } + for (size in 0..9) assertThrows(IllegalArgumentException::class.java) { parseFeedbackResultEvent(ByteArray(size)) } + } + + @Test fun successFlagMustBeExactlyZeroOrOne() { + for (flag in listOf(2, 127, 255)) { + assertThrows(IllegalArgumentException::class.java) { parseFeedbackResultEvent(result(flag = flag)) } + } + } + + @Test fun invalidUtf8AndMalformedJsonAreNotReplacedByAValidSnapshot() { + val corrupt = "{\"text\":\"".toByteArray() + byteArrayOf(0xff.toByte()) + "\"}".toByteArray() + assertThrows(CharacterCodingException::class.java) { parseFeedbackResultEvent(result(text = corrupt)) } + assertThrows(Exception::class.java) { parseFeedbackResultEvent(result(text = "{broken}".toByteArray())) } + } + + @Test fun committedAckAndCatalogRemainDifferentFromASnapshot() { + val ack = "{\"ch\":\"123\",\"message_id\":\"${"ab".repeat(32)}\",\"committed\":true,\"snapshot_pending\":true}" + val event = requireNotNull(parseFeedbackResultEvent(result(text = ack.toByteArray()))) + assertTrue(event.success); assertTrue(event.json.getBoolean("committed")); assertTrue(event.json.getBoolean("snapshot_pending")) + val catalog = requireNotNull(parseFeedbackResultEvent(result(text = "{\"catalog\":[]}".toByteArray()))) + assertTrue(catalog.json.has("catalog")); assertFalse(catalog.json.has("message_id")) + } +}