diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamTxStats.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamTxStats.kt index ae81e338..bdfdb497 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamTxStats.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamTxStats.kt @@ -7,14 +7,17 @@ data class StreamBufferPositions(val first: Long = 0, val head: Long = 0, val to else ((next.coerceIn(first, head) - first).toDouble() / (head - first)).toFloat() } -/** Все поля в байтах; ACK — десять завершённых интервалов по 100ms, для всех соединений ядра. */ +/** TX snapshot: sampleUs — MONOTONIC мкс, счётчики данных — байты, connections — число соединений. + * acked1s — десять завершённых интервалов по 100 мс для всех соединений ядра. + * buffer.first/head и peer worst/best — seq пачек, total — все получатели, peers — до 16 отставших. + * fromNative читает 13 + 4*peerCount полей JNI; peer ID сохраняет uint64, ошибки формата бросаются caller. */ data class StreamTxStats(val id: Long, val sampleUs: Long, val videoBytes: Long, val audioBytes: Long, val streamBytes: Long, val transportBytes: Long, val inflightBytes: Long, val acked1s: Long, val totalBytes: Long, val connections: Long, val buffer: StreamBufferPositions) { companion object { fun fromNative(id: Long, data: LongArray): StreamTxStats { require(data.size >= 13 && data.take(13).all { it >= 0 } && data[12] <= 16) - require(data.size == 13 + data[12].toInt() * 4 && data[9] <= data[10] && data[12] <= data[11]) + require(data.size == 13 + data[12].toInt() * 4 && data[9] <= data[10] && data[12] <= data[11] && data[11] <= Int.MAX_VALUE) val peers = List(data[12].toInt()) { i -> val offset = 13 + i * 4 require(data[offset + 1] >= 0 && data[offset + 2] >= data[offset + 1] && data[offset + 3] in 1..255) diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamMetadataTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamMetadataTest.kt new file mode 100644 index 00000000..90ae5dc6 --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/StreamMetadataTest.kt @@ -0,0 +1,46 @@ +package com.utun.chat.data + +import org.junit.Assert.* +import org.junit.Test + +class StreamMetadataTest { + @Test fun sourceIdsAndEpochsPreserveUnsigned64BitsAndEmptyNamesUseTheId() { + val source = StreamSource.parse("""[{"id":"18446744073709551615","epoch":"ffffffffffffffff","name":"","self":1}]""").single() + assertEquals(-1L, source.id); assertEquals(-1L, source.epoch) + assertEquals("18446744073709551615", source.displayName); assertTrue(source.self) + } + + @Test fun sourceSnapshotsRejectZeroAndDuplicateIdentities() { + for (json in listOf("""[{"id":"0","epoch":"1","name":"a","self":0}]""", + """[{"id":"1","epoch":"0","name":"a","self":0}]""", + """[{"id":"1","epoch":"a","name":"a","self":0},{"id":"1","epoch":"b","name":"b","self":1}]""")) { + assertThrows(IllegalArgumentException::class.java) { StreamSource.parse(json) } + } + } + + @Test fun transmitSnapshotMapsUnitsAndKeepsUnsignedPeerId() { + val data = longArrayOf(100, 1, 2, 3, 4, 5, 6, 7, 8, 10, 20, 2, 1, -1, 12, 18, 3) + val stats = StreamTxStats.fromNative(42, data) + assertEquals(100L, stats.sampleUs); assertEquals(8L, stats.connections); assertEquals(6L, stats.acked1s) + assertEquals(2, stats.buffer.total); assertEquals(StreamBufferPeer(-1, 12, 18, 3), stats.buffer.peers.single()) + assertEquals(0f, stats.buffer.fraction(0), 0f); assertEquals(0.5f, stats.buffer.fraction(15), 0f) + assertEquals(1f, stats.buffer.fraction(30), 0f); assertEquals(1f, StreamBufferPositions().fraction(0), 0f) + } + + @Test fun malformedStatsCannotOverflowCountsOrReverseTheBufferWindow() { + val valid = longArrayOf(100, 1, 2, 3, 4, 5, 6, 7, 8, 10, 20, 2, 1, -1, 12, 18, 3) + for (data in listOf(valid.copyOf(12), valid + 0, valid.copyOf().also { it[11] = Int.MAX_VALUE.toLong() + 1 }, + valid.copyOf().also { it[9] = 21 }, valid.copyOf().also { it[12] = 17 }, + valid.copyOf().also { it[15] = 11 }, valid.copyOf().also { it[16] = 256 })) { + assertThrows(IllegalArgumentException::class.java) { StreamTxStats.fromNative(42, data) } + } + } + + @Test fun nativeTelemetryKeepsKnownUnknownAndLocalReserveSeparate() { + val data = """{"self":"local","nodes":[{"name":"relay","flags":7,"us":2000000,"frames":4},{"name":"source","flags":0,"us":0,"frames":0}]}""" + val stats = StreamPlaybackTelemetry.fromNative(data.toByteArray(), 500000, 1100) + assertEquals(3, stats.nodes.size); assertEquals(2_000_000L, stats.nodes[0].durationUs) + assertTrue(stats.nodes[0].stale); assertTrue(stats.nodes[0].down); assertNull(stats.nodes[1].durationUs) + assertEquals(500_000L, stats.nodes[2].durationUs); assertEquals("1.1x", stats.speed) + } +}