From 66d133876505523a200e237b95c186ae68e861bf Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Sun, 12 Jul 2026 21:53:16 +0000 Subject: [PATCH 1/2] Honor WSJT-X Reply df on Android (answer on the requested audio tone) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When "Accept UDP requests" is enabled and a companion app (GridTracker, JTAlert, N1MM+, …) double-clicks a decode, it sends a WSJT-X Reply request whose `df` field is the audio frequency it wants us to answer on. The Android codec read that `df` and threw it away, so the reply keyed up on whatever the current TX offset happened to be instead of the requested tone. The iOS port already honors `df` (LiveEngine.swift: `if deltaFreq > 0 { waterfall.txFreqHz = ... }`); Android diverged. Fix, mirroring iOS and keeping the change scoped to the inbound-reply path (zero effect on any existing flow): - WsjtxCodec.Inbound.Reply now carries `deltaFreq`; parseInbound captures the `df` field it previously discarded. - WsjtxUdpService.ReplyHandler.onReply forwards it. - MainViewModel.handleWsjtxReply adopts it as the TX audio offset via GeneralVariables.setBaseFrequency (the shared RX/TX offset FT8TransmitSignal reads when generating the waveform), before calling the station. Because the request arrives over an open UDP socket, the untrusted df is bounded to the usable audio passband [1, MAX_SPECTRUM_WIDTH_HZ] via the extracted pure helper MainViewModel.replyTxFrequencyHz; a non-positive, absent, or out-of-range df keeps the current frequency so a malformed datagram can never push the TX tone off the band (stronger than iOS's bare `> 0` check). Tests: WsjtxCodecTest.parseReplyCapturesRequestedDeltaFreq (asserts a distinct df=2100 is surfaced, not discarded) plus the existing Reply vectors updated for the new field; new ReplyTxFrequencyTest covers the bound (honored mid-band/edge values, ignored zero/negative/over-max). Verified: :app:testDebugUnitTest (full suite) and :app:assembleDebug (4 ABIs) green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/MainViewModel.java | 20 +++++++- .../radio/ks3ckc/ft8af/wsjtx/WsjtxCodec.kt | 13 +++-- .../ks3ckc/ft8af/wsjtx/WsjtxUdpService.kt | 4 +- .../com/k1af/ft8af/ReplyTxFrequencyTest.java | 51 +++++++++++++++++++ .../ks3ckc/ft8af/wsjtx/WsjtxCodecTest.kt | 20 +++++--- 5 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/ReplyTxFrequencyTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java index 3e57ea8a..66a9cd15 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -1262,11 +1262,29 @@ private void broadcastWsjtxQso(QSLRecord r) { svc.sendQsoLogged(q, adif); } + /** + * The audio TX offset (Hz) to adopt for an inbound WSJT-X Reply's requested {@code df}, + * or -1 to keep the current frequency. Bounds the untrusted UDP value to the usable + * audio passband {@code [1, maxHz]}; a non-positive or out-of-range df is ignored (a + * malformed/garbled datagram must not push the TX tone off the audio band). + */ + static int replyTxFrequencyHz(int deltaFreq, int maxHz) { + return (deltaFreq > 0 && deltaFreq <= maxHz) ? deltaFreq : -1; + } + /** Inbound Reply: call the referenced station, preferring the real decode. */ - private void handleWsjtxReply(String call, String grid, int snr) { + private void handleWsjtxReply(String call, String grid, int snr, int deltaFreq) { if (ft8TransmitSignal == null || call == null || call.isEmpty()) { return; } + // Honor the audio frequency the companion asked us to answer on (WSJT-X's `df`), + // so we key up on the requested tone rather than the current TX offset — matching + // the iOS port. getBaseFrequency() is the shared RX/TX audio offset + // FT8TransmitSignal reads when it generates the waveform. + int txHz = replyTxFrequencyHz(deltaFreq, GeneralVariables.MAX_SPECTRUM_WIDTH_HZ); + if (txHz > 0) { + GeneralVariables.setBaseFrequency(txHz); + } Ft8Message target = findRecentDecode(call); if (target == null) { target = new Ft8Message("", call, grid == null ? "" : grid); diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodec.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodec.kt index 3e614a11..58c02fac 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodec.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodec.kt @@ -225,7 +225,14 @@ object WsjtxCodec { // --- inbound ------------------------------------------------------------ sealed class Inbound { - data class Reply(val call: String, val grid: String, val snr: Int) : Inbound() + /** + * A Reply request. [deltaFreq] is the audio frequency (Hz) the companion asked us + * to answer on — the caller should honor it so we key up on the requested tone + * rather than whatever the current TX frequency happens to be (mirrors the iOS + * port). It is the WSJT-X message's `df` field; 0 means "unspecified". + */ + data class Reply(val call: String, val grid: String, val snr: Int, val deltaFreq: Int) : + Inbound() object Replay : Inbound() data class HaltTx(val autoOnly: Boolean) : Inbound() data class FreeText(val text: String, val send: Boolean) : Inbound() @@ -278,11 +285,11 @@ object WsjtxCodec { r.i32() ?: return null // time val snr = r.i32() ?: return null r.f64() ?: return null // dt - r.i32() ?: return null // df + val deltaFreq = r.i32() ?: return null // df: the requested audio freq (Hz) r.string() ?: return null // mode val message = r.string() ?: return null val target = parseReplyTarget(message) ?: return null - Inbound.Reply(target.first, target.second, snr) + Inbound.Reply(target.first, target.second, snr, deltaFreq) } T_REPLAY -> Inbound.Replay // Reject a truncated packet rather than defaulting the trailing bool: a malformed diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxUdpService.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxUdpService.kt index 13183af9..69729d55 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxUdpService.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxUdpService.kt @@ -60,7 +60,7 @@ object WsjtxUdpService { // Java-friendly SAM interfaces so the (Java) ViewModel can wire these with // plain lambdas. fun interface StatusProvider { fun get(): WsjtxCodec.Status? } - fun interface ReplyHandler { fun onReply(call: String, grid: String, snr: Int) } + fun interface ReplyHandler { fun onReply(call: String, grid: String, snr: Int, deltaFreq: Int) } fun interface HaltHandler { fun onHalt(autoOnly: Boolean) } fun interface FreeTextHandler { fun onFreeText(text: String, send: Boolean) } fun interface ReplayHandler { fun onReplay() } @@ -210,7 +210,7 @@ object WsjtxUdpService { private fun dispatch(buf: ByteArray) { when (val msg = WsjtxCodec.parseInbound(buf)) { is WsjtxCodec.Inbound.Reply -> - mainHandler.post { onReply?.onReply(msg.call, msg.grid, msg.snr) } + mainHandler.post { onReply?.onReply(msg.call, msg.grid, msg.snr, msg.deltaFreq) } is WsjtxCodec.Inbound.HaltTx -> mainHandler.post { onHaltTx?.onHalt(msg.autoOnly) } is WsjtxCodec.Inbound.FreeText -> diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ReplyTxFrequencyTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ReplyTxFrequencyTest.java new file mode 100644 index 00000000..d6d418c0 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ReplyTxFrequencyTest.java @@ -0,0 +1,51 @@ +package com.k1af.ft8af; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Unit tests for {@link MainViewModel#replyTxFrequencyHz} — the bound applied to the + * (untrusted) audio frequency a WSJT-X companion asks us to answer on via an inbound + * Reply request's {@code df} field. A plausible df is honored so we key up on the + * requested tone; anything non-positive or beyond the audio passband is ignored so a + * malformed/garbled UDP datagram can never push the TX tone off the band. + */ +public class ReplyTxFrequencyTest { + + private static final int MAX = GeneralVariables.MAX_SPECTRUM_WIDTH_HZ; // 5000 + + @Test + public void midBandFrequency_isHonored() { + assertThat(MainViewModel.replyTxFrequencyHz(1500, MAX)).isEqualTo(1500); + } + + @Test + public void lowestValidFrequency_isHonored() { + assertThat(MainViewModel.replyTxFrequencyHz(1, MAX)).isEqualTo(1); + } + + @Test + public void maxFrequency_isHonored() { + assertThat(MainViewModel.replyTxFrequencyHz(MAX, MAX)).isEqualTo(MAX); + } + + @Test + public void zeroFrequency_keepsCurrent() { + // df == 0 means "unspecified" in the WSJT-X message. + assertThat(MainViewModel.replyTxFrequencyHz(0, MAX)).isEqualTo(-1); + } + + @Test + public void negativeFrequency_keepsCurrent() { + // A hostile/garbled df decoded as a negative int must be ignored. + assertThat(MainViewModel.replyTxFrequencyHz(-200, MAX)).isEqualTo(-1); + } + + @Test + public void aboveMax_keepsCurrent() { + // Beyond the widest possible audio passband — can't be tuned; ignore it. + assertThat(MainViewModel.replyTxFrequencyHz(MAX + 1, MAX)).isEqualTo(-1); + assertThat(MainViewModel.replyTxFrequencyHz(50_000, MAX)).isEqualTo(-1); + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodecTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodecTest.kt index 4287860b..2af8acd9 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodecTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/wsjtx/WsjtxCodecTest.kt @@ -190,12 +190,12 @@ class WsjtxCodecTest { val out = ByteArray(bb.position()); bb.flip(); bb.get(out); return out } - private fun makeReply(message: String, snr: Int): ByteArray { + private fun makeReply(message: String, snr: Int, df: Int = 1500): ByteArray { val bb = beginInbound(WsjtxCodec.T_REPLY) bb.putInt(47_000_000) // time bb.putInt(snr) bb.putDouble(0.1) // dt - bb.putInt(1500) // df + bb.putInt(df) // df putStr(bb, "FT8") putStr(bb, message) bb.put(0) // low confidence @@ -206,21 +206,29 @@ class WsjtxCodecTest { @Test fun parseReplyPlainCq() { assertThat(WsjtxCodec.parseInbound(makeReply("CQ K1ABC FN42", -3))) - .isEqualTo(WsjtxCodec.Inbound.Reply("K1ABC", "FN42", -3)) + .isEqualTo(WsjtxCodec.Inbound.Reply("K1ABC", "FN42", -3, 1500)) } @Test fun parseReplyCqWithDirective() { assertThat(WsjtxCodec.parseInbound(makeReply("CQ DX K1ABC FN42", -3))) - .isEqualTo(WsjtxCodec.Inbound.Reply("K1ABC", "FN42", -3)) + .isEqualTo(WsjtxCodec.Inbound.Reply("K1ABC", "FN42", -3, 1500)) assertThat(WsjtxCodec.parseInbound(makeReply("CQ POTA W1AW/4 EM70", 5))) - .isEqualTo(WsjtxCodec.Inbound.Reply("W1AW/4", "EM70", 5)) + .isEqualTo(WsjtxCodec.Inbound.Reply("W1AW/4", "EM70", 5, 1500)) } @Test fun parseReplyNonCqCallsSender() { assertThat(WsjtxCodec.parseInbound(makeReply("K0XYZ K1ABC -12", -12))) - .isEqualTo(WsjtxCodec.Inbound.Reply("K1ABC", "", -12)) + .isEqualTo(WsjtxCodec.Inbound.Reply("K1ABC", "", -12, 1500)) + } + + @Test + fun parseReplyCapturesRequestedDeltaFreq() { + // The `df` field carries the audio frequency the companion asked us to answer on; + // the reply must surface it (not discard it) so the caller can key up on that tone. + assertThat(WsjtxCodec.parseInbound(makeReply("CQ K1ABC FN42", -3, df = 2100))) + .isEqualTo(WsjtxCodec.Inbound.Reply("K1ABC", "FN42", -3, 2100)) } @Test From c7fc7452746654a7fec1bf60e0c060061c78ed93 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Mon, 13 Jul 2026 08:37:20 -0500 Subject: [PATCH 2/2] Clamp inbound WSJT-X Reply df to the UI tuning range [100, spectrumWidth-100] Copilot review of PR #563: the Reply-df bound accepted 1 Hz and used the fixed MAX_SPECTRUM_WIDTH_HZ (5000) regardless of the current spectrum width, so an untrusted UDP df could land the TX marker/offset outside the range the tuning UI ever permits. WaterfallView clamps click-to-tune freq_hz to [100, spectrumWidth-100]; the current width is readily available via GeneralVariables.getSpectrumWidth() (always clamped to [2500, 5000]). - replyTxFrequencyHz now bounds df to [100, spectrumWidthHz-100], honoring a plausible tone and ignoring anything the UI could never dial in. - handleWsjtxReply passes GeneralVariables.getSpectrumWidth() instead of the fixed MAX constant. - ReplyTxFrequencyTest updated for the new bounds: min (100) and max (spectrumWidth-100) honored; adds a below-min assertion (99, 1 ignored) per review comment; above-max/zero/negative still ignored. Verified: :app:testDebugUnitTest --tests ReplyTxFrequencyTest green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/MainViewModel.java | 17 +++++--- .../com/k1af/ft8af/ReplyTxFrequencyTest.java | 39 +++++++++++++------ 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java index 66a9cd15..e11e8bb8 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -1264,12 +1264,17 @@ private void broadcastWsjtxQso(QSLRecord r) { /** * The audio TX offset (Hz) to adopt for an inbound WSJT-X Reply's requested {@code df}, - * or -1 to keep the current frequency. Bounds the untrusted UDP value to the usable - * audio passband {@code [1, maxHz]}; a non-positive or out-of-range df is ignored (a - * malformed/garbled datagram must not push the TX tone off the audio band). + * or -1 to keep the current frequency. Bounds the untrusted UDP value to the same + * audio passband the tuning UI permits: {@code [100, spectrumWidthHz - 100]}, matching + * {@code WaterfallView}'s click-to-tune clamp of {@code freq_hz} to + * {@code [100, spectrumWidth - 100]}. A df below 100, above {@code spectrumWidthHz - 100}, + * non-positive, or absent is ignored — a malformed/garbled datagram, or one asking for a + * tone the user could never dial in by hand, must not push the TX marker outside the + * visible/tunable range. */ - static int replyTxFrequencyHz(int deltaFreq, int maxHz) { - return (deltaFreq > 0 && deltaFreq <= maxHz) ? deltaFreq : -1; + static int replyTxFrequencyHz(int deltaFreq, int spectrumWidthHz) { + int max = spectrumWidthHz - 100; + return (deltaFreq >= 100 && deltaFreq <= max) ? deltaFreq : -1; } /** Inbound Reply: call the referenced station, preferring the real decode. */ @@ -1281,7 +1286,7 @@ private void handleWsjtxReply(String call, String grid, int snr, int deltaFreq) // so we key up on the requested tone rather than the current TX offset — matching // the iOS port. getBaseFrequency() is the shared RX/TX audio offset // FT8TransmitSignal reads when it generates the waveform. - int txHz = replyTxFrequencyHz(deltaFreq, GeneralVariables.MAX_SPECTRUM_WIDTH_HZ); + int txHz = replyTxFrequencyHz(deltaFreq, GeneralVariables.getSpectrumWidth()); if (txHz > 0) { GeneralVariables.setBaseFrequency(txHz); } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ReplyTxFrequencyTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ReplyTxFrequencyTest.java index d6d418c0..bf5f1c7c 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/ReplyTxFrequencyTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ReplyTxFrequencyTest.java @@ -8,44 +8,59 @@ * Unit tests for {@link MainViewModel#replyTxFrequencyHz} — the bound applied to the * (untrusted) audio frequency a WSJT-X companion asks us to answer on via an inbound * Reply request's {@code df} field. A plausible df is honored so we key up on the - * requested tone; anything non-positive or beyond the audio passband is ignored so a - * malformed/garbled UDP datagram can never push the TX tone off the band. + * requested tone; anything outside the same passband the tuning UI permits + * ({@code [100, spectrumWidth - 100]}, matching {@code WaterfallView}'s click-to-tune + * clamp) is ignored, so a malformed/garbled UDP datagram — or one asking for a tone the + * user could never dial in by hand — can never push the TX marker off the visible band. */ public class ReplyTxFrequencyTest { - private static final int MAX = GeneralVariables.MAX_SPECTRUM_WIDTH_HZ; // 5000 + // A representative in-range spectrum width (the settings default). getSpectrumWidth() + // is always clamped to [MIN_SPECTRUM_WIDTH_HZ, MAX_SPECTRUM_WIDTH_HZ] = [2500, 5000]. + private static final int WIDTH = 3500; + private static final int MIN = 100; // lower tuning bound + private static final int MAX = WIDTH - 100; // upper tuning bound (3400) @Test public void midBandFrequency_isHonored() { - assertThat(MainViewModel.replyTxFrequencyHz(1500, MAX)).isEqualTo(1500); + assertThat(MainViewModel.replyTxFrequencyHz(1500, WIDTH)).isEqualTo(1500); } @Test public void lowestValidFrequency_isHonored() { - assertThat(MainViewModel.replyTxFrequencyHz(1, MAX)).isEqualTo(1); + // The UI's minimum tunable offset is 100 Hz. + assertThat(MainViewModel.replyTxFrequencyHz(MIN, WIDTH)).isEqualTo(MIN); } @Test - public void maxFrequency_isHonored() { - assertThat(MainViewModel.replyTxFrequencyHz(MAX, MAX)).isEqualTo(MAX); + public void highestValidFrequency_isHonored() { + // The UI's maximum tunable offset is spectrumWidth - 100. + assertThat(MainViewModel.replyTxFrequencyHz(MAX, WIDTH)).isEqualTo(MAX); + } + + @Test + public void belowMin_keepsCurrent() { + // Below the 100 Hz floor the tuning UI enforces — the user could never dial this in. + assertThat(MainViewModel.replyTxFrequencyHz(99, WIDTH)).isEqualTo(-1); + assertThat(MainViewModel.replyTxFrequencyHz(1, WIDTH)).isEqualTo(-1); } @Test public void zeroFrequency_keepsCurrent() { // df == 0 means "unspecified" in the WSJT-X message. - assertThat(MainViewModel.replyTxFrequencyHz(0, MAX)).isEqualTo(-1); + assertThat(MainViewModel.replyTxFrequencyHz(0, WIDTH)).isEqualTo(-1); } @Test public void negativeFrequency_keepsCurrent() { // A hostile/garbled df decoded as a negative int must be ignored. - assertThat(MainViewModel.replyTxFrequencyHz(-200, MAX)).isEqualTo(-1); + assertThat(MainViewModel.replyTxFrequencyHz(-200, WIDTH)).isEqualTo(-1); } @Test public void aboveMax_keepsCurrent() { - // Beyond the widest possible audio passband — can't be tuned; ignore it. - assertThat(MainViewModel.replyTxFrequencyHz(MAX + 1, MAX)).isEqualTo(-1); - assertThat(MainViewModel.replyTxFrequencyHz(50_000, MAX)).isEqualTo(-1); + // Beyond spectrumWidth - 100 — outside the visible/tunable range; ignore it. + assertThat(MainViewModel.replyTxFrequencyHz(MAX + 1, WIDTH)).isEqualTo(-1); + assertThat(MainViewModel.replyTxFrequencyHz(50_000, WIDTH)).isEqualTo(-1); } }