diff --git a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt index 7c408764..bca14a87 100644 --- a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt +++ b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt @@ -36,6 +36,7 @@ import com.js8call.example.R import com.js8call.example.BuildConfig import com.js8call.example.network.PskReporterClient import com.js8call.example.util.CallsignValidator +import com.js8call.example.util.Js8Commands import com.js8call.example.util.TxMessageClassifier import java.util.Calendar import java.util.Locale @@ -2909,7 +2910,7 @@ class JS8EngineService : Service() { // Try parsing as a directed command (MSG header frame) val directed = parseDirectedCommand(text) - if (directed != null && (directed.command.uppercase() == "MSG" || directed.command.uppercase().startsWith("MSG"))) { + if (directed != null && (directed.command.uppercase() == Js8Commands.CMD_MSG || directed.command.uppercase() == Js8Commands.CMD_MSG_TO)) { // This is a MSG command frame val isForMe = isSelfCallsign(callsign, directed.to) val isForMyGroup = isSubscribedGroup(directed.to) @@ -2919,23 +2920,9 @@ class JS8EngineService : Service() { return } - // Extract any inline payload from concatenated format (MSGpayload) - val inlinePayload = if (directed.command.uppercase().startsWith("MSG") && directed.command.length > 3) { - directed.command.substring(3) - } else { - "" - } - - // Combine inline payload with any additional payload tokens - val initialPayload = if (inlinePayload.isNotBlank() && directed.payload.isNotBlank()) { - "$inlinePayload ${directed.payload}" - } else if (inlinePayload.isNotBlank()) { - inlinePayload - } else { - directed.payload - } + val initialPayload = directed.payload - Log.d(TAG, "maybeHandleIncomingMessage: MSG command from=${directed.from} to=${directed.to} inlinePayload='$inlinePayload' initialPayload='$initialPayload' isLastFrame=${isLastFrame(type)}") + Log.d(TAG, "maybeHandleIncomingMessage: MSG command from=${directed.from} to=${directed.to} initialPayload='$initialPayload' isLastFrame=${isLastFrame(type)}") // If this is the last frame and we have payload, deliver immediately if (isLastFrame(type) && initialPayload.isNotBlank()) { @@ -3239,25 +3226,30 @@ class JS8EngineService : Service() { } var to = toToken - var command: String - var payloadStart = index + 1 + val command: String + val payload: String if (toToken.endsWith(">")) { to = toToken.trimEnd('>') command = ">" + payload = tokens.drop(index + 1).joinToString(" ") } else { if (index + 1 >= tokens.size) return null - command = tokens[index + 1] - payloadStart = index + 2 + val remainder = tokens.drop(index + 1).joinToString(" ") + val match = Js8Commands.matchAt(remainder) + if (match != null) { + command = match.command + payload = match.payload + } else { + // Unknown text keeps the single-token shape, so freetext frames + // reach callers unchanged. + command = tokens[index + 1] + payload = tokens.drop(index + 2).joinToString(" ") + } } if (to.isBlank() || command.isBlank()) return null if (from.isBlank() && command != ">") return null - val payload = if (payloadStart < tokens.size) { - tokens.subList(payloadStart, tokens.size).joinToString(" ") - } else { - "" - } return DirectedCommand(from, to, command, payload) } diff --git a/android/app/src/main/java/com/js8call/example/util/Js8Commands.kt b/android/app/src/main/java/com/js8call/example/util/Js8Commands.kt new file mode 100644 index 00000000..503dd825 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/util/Js8Commands.kt @@ -0,0 +1,108 @@ +package com.js8call.example.util + +/** + * The JS8 directed command vocabulary, mirroring kDirectedCmds in + * core/src/protocol/varicode.cpp. Js8CommandsTest fails when the two drift. + * Names are stored without the leading space the native table carries. + */ +object Js8Commands { + + const val CMD_MSG = "MSG" + const val CMD_MSG_TO = "MSG TO:" + const val CMD_QUERY = "QUERY" + const val CMD_QUERY_MSGS = "QUERY MSGS" + const val CMD_QUERY_CALL = "QUERY CALL" + const val CMD_ACK = "ACK" + const val CMD_NACK = "NACK" + const val CMD_YES = "YES" + const val CMD_NO = "NO" + + /** Command name to the number the protocol packs it as. */ + val COMMANDS: Map = mapOf( + "HEARTBEAT" to -1, + "HB" to -1, + "CQ" to -1, + "SNR?" to 0, + "?" to 0, + "DIT DIT" to 1, + "NACK" to 2, + "HEARING?" to 3, + "GRID?" to 4, + ">" to 5, + "STATUS?" to 6, + "STATUS" to 7, + "HEARING" to 8, + "MSG" to 9, + "MSG TO:" to 10, + "QUERY" to 11, + "QUERY MSGS" to 12, + "QUERY MSGS?" to 12, + "QUERY CALL" to 13, + "ACK" to 14, + "GRID" to 15, + "INFO?" to 16, + "INFO" to 17, + "FB" to 18, + "HW CPY?" to 19, + "SK" to 20, + "RR" to 21, + "QSL?" to 22, + "QSL" to 23, + "CMD" to 24, + "SNR" to 25, + "NO" to 26, + "YES" to 27, + "73" to 28, + "HEARTBEAT SNR" to 29, + "AGN?" to 30 + ) + + /** + * Commands whose payload spans data frames, so the receiver has to buffer + * until the last-frame bit before the text is complete. + */ + val BUFFERED: Set = setOf(5, 9, 10, 11, 12, 13, 15, 24) + + /** Commands that carry a trailing checksum, and its width in bits. */ + val CHECKSUMMED: Map = mapOf( + 5 to 16, 9 to 16, 10 to 16, 11 to 16, 12 to 16, 13 to 16, 15 to 0, 24 to 16 + ) + + /** Longest name first, so QUERY MSGS wins over QUERY and MSG TO: over MSG. */ + private val byLongestName: List = + COMMANDS.keys.sortedByDescending { it.length } + + data class Match(val command: String, val payload: String) + + /** + * Match a command at the head of [remainder], everything after the addressed + * callsign. Runs on the raw string rather than tokens: several names span + * two words, and a name ending in ':' can have its argument glued to it, + * as in MSG TO:KN4CRD. + */ + fun matchAt(remainder: String): Match? { + val text = remainder.trimStart() + if (text.isEmpty()) return null + + for (name in byLongestName) { + if (!text.regionMatches(0, name, 0, name.length, ignoreCase = true)) continue + + // A name ending in ':' may be followed immediately by its argument. + // Every other name has to end on a token boundary, so that MSG does + // not match the front of MSGS. + if (!name.endsWith(":")) { + val next = text.getOrNull(name.length) + if (next != null && !next.isWhitespace()) continue + } + + return Match(name, text.substring(name.length).trim()) + } + return null + } + + fun isBuffered(command: String): Boolean = + COMMANDS[command.uppercase()]?.let { BUFFERED.contains(it) } == true + + fun isChecksummed(command: String): Boolean = + COMMANDS[command.uppercase()]?.let { CHECKSUMMED.containsKey(it) } == true +} diff --git a/android/app/src/test/java/com/js8call/example/util/Js8CommandsTest.kt b/android/app/src/test/java/com/js8call/example/util/Js8CommandsTest.kt new file mode 100644 index 00000000..339e5425 --- /dev/null +++ b/android/app/src/test/java/com/js8call/example/util/Js8CommandsTest.kt @@ -0,0 +1,116 @@ +package com.js8call.example.util + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class Js8CommandsTest { + + @Test + fun matchesMultiWordCommandsWhole() { + assertEquals(Js8Commands.Match("QUERY MSGS", ""), Js8Commands.matchAt("QUERY MSGS")) + assertEquals(Js8Commands.Match("QUERY MSGS?", ""), Js8Commands.matchAt("QUERY MSGS?")) + assertEquals(Js8Commands.Match("QUERY CALL", "KN4CRD"), Js8Commands.matchAt("QUERY CALL KN4CRD")) + assertEquals(Js8Commands.Match("HEARTBEAT SNR", "-10"), Js8Commands.matchAt("HEARTBEAT SNR -10")) + assertEquals(Js8Commands.Match("HW CPY?", ""), Js8Commands.matchAt("HW CPY?")) + assertEquals(Js8Commands.Match("DIT DIT", ""), Js8Commands.matchAt("DIT DIT")) + } + + /** + * QUERY MSG {id} is command QUERY with a MSG argument, not a command of its + * own. The desktop dispatches it the same way. + */ + @Test + fun treatsQueryMsgIdAsQueryWithArgument() { + assertEquals(Js8Commands.Match("QUERY", "MSG 3"), Js8Commands.matchAt("QUERY MSG 3")) + } + + @Test + fun acceptsMsgToWithAndWithoutSpaceAfterColon() { + assertEquals(Js8Commands.Match("MSG TO:", "KN4CRD HELLO"), Js8Commands.matchAt("MSG TO:KN4CRD HELLO")) + assertEquals(Js8Commands.Match("MSG TO:", "KN4CRD HELLO"), Js8Commands.matchAt("MSG TO: KN4CRD HELLO")) + } + + @Test + fun requiresTokenBoundaryForNamesWithoutColon() { + // MSG must not match the front of MSGS + assertEquals(Js8Commands.Match("MSG", "HELLO"), Js8Commands.matchAt("MSG HELLO")) + val msgs = Js8Commands.matchAt("MSGS HELLO") + assertTrue("MSGS should not match MSG", msgs == null || msgs.command != "MSG") + } + + @Test + fun matchesSingleWordCommandsAndTheirPayloads() { + assertEquals(Js8Commands.Match("SNR?", ""), Js8Commands.matchAt("SNR?")) + assertEquals(Js8Commands.Match("GRID?", ""), Js8Commands.matchAt("GRID?")) + assertEquals(Js8Commands.Match("INFO?", ""), Js8Commands.matchAt("INFO?")) + assertEquals(Js8Commands.Match("STATUS?", ""), Js8Commands.matchAt("STATUS?")) + assertEquals(Js8Commands.Match("HEARING?", ""), Js8Commands.matchAt("HEARING?")) + assertEquals(Js8Commands.Match("AGN?", ""), Js8Commands.matchAt("AGN?")) + assertEquals(Js8Commands.Match("ACK", ""), Js8Commands.matchAt("ACK")) + assertEquals(Js8Commands.Match("MSG", "HELLO THERE"), Js8Commands.matchAt("MSG HELLO THERE")) + } + + @Test + fun reportsBufferedAndChecksummedCommands() { + assertTrue(Js8Commands.isBuffered("MSG")) + assertTrue(Js8Commands.isBuffered("MSG TO:")) + assertTrue(Js8Commands.isBuffered("QUERY MSGS")) + assertTrue(Js8Commands.isChecksummed("MSG TO:")) + } + + @Test + fun returnsNullForUnknownText() { + assertNull(Js8Commands.matchAt("HELLO WORLD")) + assertNull(Js8Commands.matchAt("")) + } + + /** + * Drift guard. The native table is the one the protocol actually packs + * against, so a divergence here is a wire-format bug, not a style problem. + */ + @Test + fun kotlinTableMatchesNativeVaricodeTable() { + val source = findVaricodeSource() + assertNotNull( + "Could not locate core/src/protocol/varicode.cpp from ${File("").absolutePath}", + source + ) + + val body = source!!.readText() + val start = body.indexOf("kDirectedCmds") + assertTrue("kDirectedCmds not found in ${source.path}", start >= 0) + val open = body.indexOf('{', start) + val close = body.indexOf("};", open) + assertTrue("kDirectedCmds block is malformed", open in 0 until close) + + val entry = Regex("""\{\s*"([^"]*)"\s*,\s*(-?\d+)\s*\}""") + val native = mutableMapOf() + for (m in entry.findAll(body.substring(open, close))) { + val name = m.groupValues[1].trim() + // The blank names are the free-text sentinel, which is not a command + if (name.isEmpty()) continue + native[name] = m.groupValues[2].toInt() + } + + assertTrue("Parsed no entries from the native table", native.size > 20) + assertEquals( + "Kotlin and native directed-command tables disagree", + native.toSortedMap(), + Js8Commands.COMMANDS.toSortedMap() + ) + } + + private fun findVaricodeSource(): File? { + var dir: File? = File("").absoluteFile + while (dir != null) { + val candidate = File(dir, "core/src/protocol/varicode.cpp") + if (candidate.isFile) return candidate + dir = dir.parentFile + } + return null + } +}