Reproduce malformed feedback event handling in the UI

This commit is contained in:
evgeny committed 2026-10-28 11:36:13 +01:00
1 parent a1ce486e89
commit 317f362a7a
3 files changed
+107 -14

No files matched your search

@@ -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<ReactionState>, 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<Int>, val custom: String)
@@ -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
@@ -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"))
}
}