Reproduce malformed feedback event handling in the UI
This commit is contained in:
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"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user