From 5b992647c5e3dd20fbc6a8bb8745107d67bcb512 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Mon, 20 Jul 2026 19:01:53 -0500 Subject: [PATCH 001/113] Debug: drop "debug inject" POTA notes label so demo screenshots read clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DebugInject started a demo POTA activation with notes="debug inject", which renders as a caption on the POTA activation card — fine for the emulator harness, but it leaks into Play Store screenshots. Pass notes=null instead, matching a real no-notes activation (PotaScreen calls start(..., notes.ifBlank { null })), so the demo card shows just the park/QSO/elapsed with no caption. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/debug/kotlin/radio/ks3ckc/ft8af/car/DebugInject.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ft8af/app/src/debug/kotlin/radio/ks3ckc/ft8af/car/DebugInject.kt b/ft8af/app/src/debug/kotlin/radio/ks3ckc/ft8af/car/DebugInject.kt index 796dafa7c..5f6734bb9 100644 --- a/ft8af/app/src/debug/kotlin/radio/ks3ckc/ft8af/car/DebugInject.kt +++ b/ft8af/app/src/debug/kotlin/radio/ks3ckc/ft8af/car/DebugInject.kt @@ -137,7 +137,9 @@ internal fun applyDebugInject(spec: DebugInjectSpec, vm: MainViewModel) { } } - spec.parkRef?.let { PotaSessionManager.start(listOf(it), "debug inject") } + // notes=null matches a real no-notes activation (PotaScreen passes + // notes.ifBlank { null }); keeps the demo POTA card clean for screenshots. + spec.parkRef?.let { PotaSessionManager.start(listOf(it), null) } // Demo logbook QSOs — written straight into QSLTable through the app's own // insert path, so the Logbook tab (which re-queries the DB on load, not the From 8528cff62a6c404698b8d99f322e87d5caa70e08 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Tue, 21 Jul 2026 21:25:04 +0000 Subject: [PATCH 002/113] Fix NPE crash on decode screen when a message has a null destination call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ft8Message.checkIsCQ() dereferenced callsignTo (callsignTo.trim().split(...)) before its guard, and that guard checked the wrong variable: `if (s == null)` after `String s = callsignTo.trim().split(" ")[0]` is dead, because String.split()[0] is never null. The value that can actually be null is callsignTo itself — it defaults to null and stays null for free-text/telemetry frames and unresolved-hash decodes that still reach the published decode list. The Compose decode screen calls checkIsCQ() unconditionally on the main thread with no try/catch — DecodeRow (every rendered row), resolveQsoStatus, and DecodeScreen.filterMessages — so such a message crashed the whole app on the primary screen. This is proven reachable by the existing #254 guard in ActiveQsoPanel (`if (msg.callsignTo != null && msg.checkIsCQ())`), a sibling consumer of the same mainViewModel.mutableFt8MessageList; that guard was added to ActiveQsoPanel but not to the decode-list consumers. Root-cause fix: null-guard callsignTo inside checkIsCQ() itself, the single choke point through which every caller funnels, so a missing destination is treated as "not a CQ" instead of throwing. Behaviour is unchanged for every message that has a callsignTo. Test: Ft8MessageTest.checkIsCQ_falseWhenCallsignToNull (throws NPE before the fix, passes after). Full :app:testDebugUnitTest and :app:assembleDebug (all 4 ABIs) verified green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/java/com/k1af/ft8af/Ft8Message.java | 14 ++++++++++---- .../test/java/com/k1af/ft8af/Ft8MessageTest.java | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/Ft8Message.java b/ft8af/app/src/main/java/com/k1af/ft8af/Ft8Message.java index d2694b06f..54e5e06e9 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/Ft8Message.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/Ft8Message.java @@ -502,12 +502,18 @@ public String getToMaidenheadGrid(DatabaseOpr db) { * @return boolean Returns true if CQ. */ public boolean checkIsCQ() { - String s = callsignTo.trim().split(" ")[0]; - if (s == null) { + // callsignTo defaults to null and stays null for free-text/telemetry + // frames and unresolved-hash decodes that still reach the decode list. + // The Compose decode screen calls this unconditionally on the main + // thread (DecodeRow / resolveQsoStatus / filterMessages), so a missing + // destination must be treated as "not a CQ" rather than NPE on trim(). + // (The previous `s == null` check was dead: String.split()[0] is never + // null; the deref that actually throws is callsignTo above.) + if (callsignTo == null) { return false; - } else { - return (s.equals("CQ") || s.equals("DE") || s.equals("QRZ")); } + String s = callsignTo.trim().split(" ")[0]; + return (s.equals("CQ") || s.equals("DE") || s.equals("QRZ")); } /** diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/Ft8MessageTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/Ft8MessageTest.java index 4806b221d..45d42b8fa 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/Ft8MessageTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/Ft8MessageTest.java @@ -87,6 +87,20 @@ public void checkIsCQ_falseWhenAddressedToCallsign() { assertThat(new Ft8Message("K1ABC", "W1AW", "FN42").checkIsCQ()).isFalse(); } + @Test + public void checkIsCQ_falseWhenCallsignToNull() { + // A free-text / telemetry / unresolved-hash decode can reach the decode + // list with callsignTo still at its null default (single-arg ctor). The + // Compose decode screen calls checkIsCQ() unconditionally on every + // rendered row (DecodeRow / resolveQsoStatus / filterMessages) on the + // main thread with no try/catch, so a missing destination must return + // "not a CQ" rather than NPE. Mirrors the #254 guard already present in + // ActiveQsoPanel for the same deref. + Ft8Message msg = new Ft8Message(FT8Common.FT8_MODE); + assertThat(msg.callsignTo).isNull(); + assertThat(msg.checkIsCQ()).isFalse(); + } + @Test public void getMessageText_freeTextPadsToThirteen() { // Default i3/n3 == 0 selects the free-text branch, which upper-cases and From ca93e5f05cf142508c36c1715cccc46030f7c96c Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Tue, 21 Jul 2026 23:13:14 +0000 Subject: [PATCH 003/113] Guarantee TX teardown when the AudioTrack sound-card path throws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FT8 sound-card TX branch of playFT8Signal claimed exclusive audio focus and kept PTT keyed, then built and drove a streaming AudioTrack — new AudioTrack()/play()/write() can all throw (bad route/rate, DEAD_OBJECT, an uninitialized track). afterPlayAudio() (which drops PTT, releases the track, and abandons audio focus) was only reached on the straight-line normal exit, so a mid-setup/mid-write throw skipped it. DoTransmitRunnable, the TX worker on doTransmitThreadPool, has no top-level try/catch, so the escaping exception (a) crashed the whole app and (b) left PTT keyed into the RX window, audio focus held (other apps stay ducked until process death), and the AudioTrack leaked — a stuck carrier is never acceptable. The parallel Tune path (playTuneTone) already guards this with try/catch/finally; the FT8 branch #597 added did not. Fix: extract the sound-card body into playViaAudioTrack (sibling of playViaUsbAudio) and run it under a new package-private runPlaybackWithTeardown(body, teardown) that swallow-and-logs a body failure and always runs teardown exactly once — mirroring playTuneTone. Happy path is byte-identical (same single afterPlayAudio() call). RunPlaybackWithTeardownTest added (pure JVM): teardown runs exactly once on normal completion and on a thrown body, and a body exception never propagates. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ft8af/ft8transmit/FT8TransmitSignal.java | 49 +++++++++++- .../RunPlaybackWithTeardownTest.java | 75 +++++++++++++++++++ 2 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/RunPlaybackWithTeardownTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java index 0e33b22bf..0cb6bc673 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java @@ -755,6 +755,24 @@ private void playFT8Signal(Ft8Message msg) { GeneralVariables.fileLog( "playFT8Signal: using AudioTrack output (Android default sink)"); + // Own the whole sound-card branch under a guaranteed single teardown. + // Everything below claims audio focus and keeps PTT keyed, then builds + // and drives an AudioTrack — any of which can throw on a bad route/rate, + // a DEAD_OBJECT, or an uninitialized track. DoTransmitRunnable (the TX + // worker) has no top-level try/catch, so an escaping exception would + // crash the app AND strand PTT keyed + audio focus held. Mirrors + // playTuneTone's try/catch/finally. + runPlaybackWithTeardown(() -> playViaAudioTrack(buffer), this::afterPlayAudio); + } + + /** + * Sound-card (Android default sink) FT8 TX playback: claim exclusive audio + * focus, build and drive the streaming AudioTrack, and drain the tail. Must + * run under {@link #runPlaybackWithTeardown} so PTT/focus/track teardown + * ({@link #afterPlayAudio}) always happens, even if an AudioTrack call + * throws. Sibling of {@link #playViaUsbAudio}. + */ + private void playViaAudioTrack(float[] buffer) { // This branch shares Android's mixer with every other app, so claim // exclusive focus for the transmission. Denial is log-only: TX must // still go out. @@ -871,10 +889,33 @@ private void playFT8Signal(Ft8Message msg) { } } - // Worker-thread-owned teardown (drops PTT + releases the track). The UI - // thread never releases the streaming track — it only flips txAudioCancelled - // and pauses/flushes — so there's no release race against this write loop. - afterPlayAudio(); + // Teardown (drops PTT + releases the track + audio focus) is run by + // runPlaybackWithTeardown's finally, so it happens on both the normal + // exit here and any thrown AudioTrack failure above. The UI thread never + // releases the streaming track — it only flips txAudioCancelled and + // pauses/flushes — so there's no release race against this write loop. + } + + /** + * Runs a sound-card playback {@code body}, then {@code teardown} exactly + * once — whether the body returns normally or throws. The AudioTrack + * setup/play/write in the body can throw (device route change, DEAD_OBJECT, + * an uninitialized track) and the TX worker ({@link DoTransmitRunnable}) has + * no top-level try/catch, so an escaping exception would crash the app and + * leave PTT keyed + audio focus held + the track leaked — a stuck carrier is + * never acceptable. Swallow-and-log the failure and always tear down, + * mirroring {@link #playTuneTone}. + * + *

Package-visible and free of Android types for testing. + */ + static void runPlaybackWithTeardown(Runnable body, Runnable teardown) { + try { + body.run(); + } catch (Exception e) { + Log.e(TAG, "FT8 AudioTrack playback failed: " + e); + } finally { + teardown.run(); + } } /** diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/RunPlaybackWithTeardownTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/RunPlaybackWithTeardownTest.java new file mode 100644 index 000000000..137bfe9ee --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/RunPlaybackWithTeardownTest.java @@ -0,0 +1,75 @@ +package com.k1af.ft8af.ft8transmit; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Unit tests for {@link FT8TransmitSignal#runPlaybackWithTeardown} — the guard + * that keeps a thrown AudioTrack failure from (a) escaping the top-level-catch-less + * TX worker (whole-app crash) and (b) stranding PTT keyed + audio focus held + + * the track leaked. The contract: teardown runs exactly once whether the body + * returns normally or throws, and a body exception never propagates. + * + *

Pure JVM (no Robolectric): the method takes plain {@link Runnable}s and its + * only Android touch is {@code Log.e}, which {@code returnDefaultValues} stubs. + */ +public class RunPlaybackWithTeardownTest { + + @Test + public void bodyCompletes_runsBodyThenTeardownExactlyOnce() { + AtomicInteger bodyRuns = new AtomicInteger(); + AtomicInteger teardownRuns = new AtomicInteger(); + + FT8TransmitSignal.runPlaybackWithTeardown( + bodyRuns::incrementAndGet, teardownRuns::incrementAndGet); + + assertThat(bodyRuns.get()).isEqualTo(1); + assertThat(teardownRuns.get()).isEqualTo(1); + } + + @Test + public void bodyThrows_runsTeardownAndSwallowsTheException() { + AtomicInteger teardownRuns = new AtomicInteger(); + + // A throwing body must NOT propagate: the real body runs on the + // doTransmitThreadPool worker, whose DoTransmitRunnable has no + // top-level try/catch, so an escaping exception crashes the app. + FT8TransmitSignal.runPlaybackWithTeardown(() -> { + throw new IllegalStateException("AudioTrack died mid-write"); + }, teardownRuns::incrementAndGet); + + // If the exception were not swallowed this test method would fail with + // that IllegalStateException instead of reaching the assertion. + assertThat(teardownRuns.get()).isEqualTo(1); + } + + @Test + public void bodyThrows_stillTearsDownEvenWhenBodyDidPartialWork() { + AtomicInteger teardownRuns = new AtomicInteger(); + AtomicInteger bodyProgress = new AtomicInteger(); + + FT8TransmitSignal.runPlaybackWithTeardown(() -> { + bodyProgress.incrementAndGet(); // e.g. focus acquired / PTT keyed + throw new IllegalArgumentException("bad audio route"); + }, teardownRuns::incrementAndGet); + + assertThat(bodyProgress.get()).isEqualTo(1); + assertThat(teardownRuns.get()).isEqualTo(1); + } + + @Test + public void teardownRunsOncePerCall_notCumulatively() { + AtomicInteger teardownRuns = new AtomicInteger(); + + FT8TransmitSignal.runPlaybackWithTeardown(() -> { }, teardownRuns::incrementAndGet); + FT8TransmitSignal.runPlaybackWithTeardown(() -> { + throw new RuntimeException("second call throws"); + }, teardownRuns::incrementAndGet); + + // One teardown per invocation regardless of body outcome. + assertThat(teardownRuns.get()).isEqualTo(2); + } +} From 03399a9a953f2a9cc66d25a272dfb4988d4e1247 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 00:11:31 +0000 Subject: [PATCH 004/113] Don't upload the RR73 sign-off as a locator to PSKReporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: PskReporterSender.toSpotRecord gated the reported senderLocator on `msg.maidenGrid?.takeIf { it.length >= 4 }` — a naive length check. The JNI decoder stores the end-of-QSO sign-off token "RR73" into Ft8Message.maidenGrid because it is a syntactic 4-char grid look-alike (R,R in the A-R field range; 7,3 in the 0-9 square range). "RR73" is not a location: ft8_lib `packgrid` always packs "RR73" as the roger-73 report (MAXGRID4 + 3), never as a grid, so no compliant FT8 transmitter ever means grid RR73 (a phantom cell in the Arctic Ocean), and WSJT-X never treats it as one. Effect: every QSO-ending RR73 the app decodes was uploaded to the global PSKReporter spot database as the sender's Maidenhead locator "RR73", polluting a shared ecosystem resource with phantom Arctic coordinates. The rest of the app already excludes this token wherever it classifies a grid — GeneralVariables.checkFun1 (`!extraInfo.equals("RR73")`), MaidenheadGrid.gridToLatLng (returns null for RR73), and CountDbOpr's distance stats. The PSKReporter path was the one consumer that missed it. Fix: extract the decision into a pure, testable `reportableLocator()` helper that keeps genuine grids but drops the RR73 sign-off (and, as before, absent/too-short grids). Behaviour is unchanged for every real locator. No protocol/DSP/threading change. Testing: added reportableLocator cases to PskReporterSenderTest (keeps FN31/IO91wm, drops RR73/rr73/null/empty/short); the RR73 case fails against the old length-only check and passes after the fix. Full :app:testDebugUnitTest suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ft8af/pskreporter/PskReporterSender.kt | 29 +++++++++++++++++- .../pskreporter/PskReporterSenderTest.kt | 30 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSender.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSender.kt index 6f277c081..cbae3a1dc 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSender.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSender.kt @@ -191,11 +191,38 @@ object PskReporterSender { frequencyHz = freqHz, snr = if (msg.hasSnr()) msg.snr else 0, mode = mode, - senderLocator = msg.maidenGrid?.takeIf { it.length >= 4 }, + senderLocator = reportableLocator(msg.maidenGrid), flowStartSeconds = msg.utcTime / 1000, ) } + /** + * Decide whether a decoded message's grid slot is a genuine Maidenhead + * locator worth reporting to PSKReporter, or a value that must be dropped. + * + * A standard/non-standard FT8 message ends a QSO with the sign-off token + * "RR73", which the JNI decoder stores into [Ft8Message.maidenGrid] because + * it is a syntactic 4-char grid look-alike (R,R in field range A–R; 7,3 in + * square range 0–9). It is NOT a location: the FT8 encoder always packs + * "RR73" as the roger-73 report, never as a grid (see ft8_lib `packgrid`), + * so no compliant transmitter ever means grid RR73 (a phantom cell in the + * Arctic Ocean). Reporting it would inject bogus locators into the global + * PSKReporter spot database and break interoperability with WSJT-X, which + * never treats RR73 as a grid. + * + * This mirrors the RR73 exclusion the rest of the app already applies when + * classifying a grid — [GeneralVariables.checkFun1], + * `MaidenheadGrid.gridToLatLng`, and `CountDbOpr` — which the naive + * `length >= 4` check here previously missed. Pure function — testable + * without Android. + * + * @return the locator to report, or null when the grid is absent, too short + * to be a locator, or the RR73 sign-off token. + */ + @VisibleForTesting + internal fun reportableLocator(maidenGrid: String?): String? = + maidenGrid?.takeIf { it.length >= 4 && !it.equals("RR73", ignoreCase = true) } + /** * Atomically test the per-band dedup window for [dedupKey] ("CALL|BAND") and, * when it has not been reported within [DEDUP_WINDOW_MS], record [nowMs] as its diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSenderTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSenderTest.kt index 60a37f572..d7cb2f05b 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSenderTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSenderTest.kt @@ -501,6 +501,36 @@ class PskReporterSenderTest { .isEqualTo("FT8AF 1.0.2") } + // --------------------------------------------------------------- + // Sender locator sanitation (reportableLocator) + // --------------------------------------------------------------- + + @Test + fun `reportableLocator keeps a genuine 4-char grid`() { + assertThat(PskReporterSender.reportableLocator("FN31")).isEqualTo("FN31") + } + + @Test + fun `reportableLocator keeps a 6-char grid`() { + assertThat(PskReporterSender.reportableLocator("IO91wm")).isEqualTo("IO91wm") + } + + @Test + fun `reportableLocator drops the RR73 signoff token`() { + // "RR73" is a syntactic grid look-alike (R,R + 7,3) but is the roger-73 + // sign-off, never a Maidenhead locator. It must not be uploaded to the + // global PSKReporter database. The old `length >= 4` check let it through. + assertThat(PskReporterSender.reportableLocator("RR73")).isNull() + assertThat(PskReporterSender.reportableLocator("rr73")).isNull() + } + + @Test + fun `reportableLocator drops absent or too-short grids`() { + assertThat(PskReporterSender.reportableLocator(null)).isNull() + assertThat(PskReporterSender.reportableLocator("")).isNull() + assertThat(PskReporterSender.reportableLocator("FN")).isNull() + } + // --------------------------------------------------------------- // Dedup window bookkeeping (markIfFresh) + thread-safety // --------------------------------------------------------------- From 621df421ffcb8917c76deaaa7333c2cedf20136c Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 02:08:52 +0000 Subject: [PATCH 005/113] Fix Yaesu FT-817/857/897 set-frequency encoder tens-of-Hz nibble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yaesu2RigConstant.setOperationFreq packed the low nibble of the last BCD byte as `freq % 100` — the full 0-99 sub-100 Hz remainder crammed into a single 4-bit nibble. The rig's own decoder (Yaesu2Command.getFrequency) weights that nibble x10 as the tens-of-Hz digit, so the encoder and decoder were not inverses: setOperationFreq(14_074_050) round-tripped to 14_074_320 (+270 Hz), and endings whose remainder exceeded 15 produced a non-BCD nibble (>1 kHz off). On live TX this keys the rig on the wrong VFO for any dial that is not 100 Hz-aligned (10-Hz-resolution reads, custom bands per issue #470), pushing the FT8 signal off frequency. Root cause: the last nibble must be the tens-of-Hz digit `freq % 100 / 10` (the sub-10 Hz digit is below the rig's CAT resolution and is correctly dropped). This makes the encoder an exact inverse of the decoder for any 10 Hz-aligned VFO. PR #497 fixed the matching decoder and explicitly left this encoder quirk untouched; this completes the pair. Pure-JVM tests added/updated in Yaesu2RigConstantTest and Yaesu2CommandTest (tens-of-Hz encoding, full round-trip, sub-10 Hz drop); confirmed red against the old encoder, green with the fix. Full :app:testDebugUnitTest suite passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../k1af/ft8af/rigs/Yaesu2RigConstant.java | 6 ++++- .../k1af/ft8af/rigs/Yaesu2CommandTest.java | 22 +++++++++++++------ .../ft8af/rigs/Yaesu2RigConstantTest.java | 11 ++++++++++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu2RigConstant.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu2RigConstant.java index d56a6d060..528ef2358 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu2RigConstant.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu2RigConstant.java @@ -77,11 +77,15 @@ public static byte[] sendDisconnectData() { return GET_DISCONNECT; } public static byte[] setOperationFreq(long freq) { + // 4 BCD bytes, most-significant nibble first, weights 1e8..1e1 (10 Hz LSB). + // The last nibble is the tens-of-Hz digit (freq % 100 / 10), NOT freq % 100: + // Yaesu2Command.getFrequency weights it x10, so packing the full 0-99 remainder + // there desynced the encoder from its own decoder (e.g. ...050 read back as ...320). byte[] data = new byte[]{ (byte) (((byte) (freq % 1000000000 / 100000000) << 4) + (byte) (freq % 100000000 / 10000000)) , (byte) (((byte) (freq % 10000000 / 1000000) << 4) + (byte) (freq % 1000000 / 100000)) , (byte) (((byte) (freq % 100000 / 10000) << 4) + (byte) (freq % 10000 / 1000)) - , (byte) (((byte) (freq % 1000 / 100) << 4) + (byte) (freq % 100)) + , (byte) (((byte) (freq % 1000 / 100) << 4) + (byte) (freq % 100 / 10)) ,(byte) 0x01 }; return data; diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu2CommandTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu2CommandTest.java index c163ff1ea..111f07ae9 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu2CommandTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu2CommandTest.java @@ -12,9 +12,9 @@ * sequence with weights 1e8, 1e7, 1e6, 1e5, 1e4, 1e3, 1e2, 1e1 (10 Hz LSB). * * This inverts {@link Yaesu2RigConstant#setOperationFreq} exactly for - * 100 Hz-aligned dials. The encoder packs the last nibble as {@code freq % 100} - * rather than a clean tens-of-Hz digit, so for frequencies with a non-zero - * sub-100 Hz remainder the encoder and decoder are not exact inverses. + * 10 Hz-aligned dials. The encoder packs the last nibble as the tens-of-Hz digit + * ({@code freq % 100 / 10}); only the sub-10 Hz remainder (below the rig's CAT + * resolution) is dropped, so the two are exact inverses for any 10 Hz-aligned VFO. */ public class Yaesu2CommandTest { @@ -41,14 +41,22 @@ public void getFrequency_decodesHundredsAndTensOfHzDigits() { @Test public void getFrequency_roundTripsSetOperationFreqEncoding() { - // The decoder must invert the encoder. Use a 100 Hz-aligned dial so the - // round trip isolates the decoder weights (the encoder packs the two - // sub-100 Hz digits into a single nibble, an unrelated limitation). - long dial = 14_074_300L; // 14.074 MHz + 300 Hz, exercises the low nibbles + // The decoder must invert the encoder for any 10 Hz-aligned dial. This dial + // has non-zero hundreds- and tens-of-Hz digits, exercising both nibbles of + // the last byte (which previously desynced: 50 packed raw read back as 320). + long dial = 14_074_350L; // 14.074 MHz + 350 Hz byte[] encoded = Yaesu2RigConstant.setOperationFreq(dial); assertThat(Yaesu2Command.getFrequency(encoded)).isEqualTo(dial); } + @Test + public void getFrequency_dropsOnlySub10HzOnRoundTrip() { + // The rig CAT frame has 10 Hz resolution, so the sub-10 Hz remainder is the + // only part not preserved; everything at or above 10 Hz round-trips exactly. + byte[] encoded = Yaesu2RigConstant.setOperationFreq(14_074_058L); + assertThat(Yaesu2Command.getFrequency(encoded)).isEqualTo(14_074_050L); + } + @Test public void getFrequency_allZero_isZero() { byte[] raw = {0x00, 0x00, 0x00, 0x00, 0x00}; diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu2RigConstantTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu2RigConstantTest.java index 695b358ab..9cc410f35 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu2RigConstantTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu2RigConstantTest.java @@ -32,6 +32,17 @@ public void setOperationFreq_encodesBcdWithTrailer() { assertThat(f).isEqualTo(expected); } + @Test + public void setOperationFreq_packsTensOfHzInLastNibble() { + // 14.074050 MHz: the 50 Hz component is the tens-of-Hz digit (5), which must + // land in the low nibble of the last byte -> 0x05, not the raw remainder 50 + // (0x32). Packing 50 there used to desync the encoder from getFrequency, which + // weights that nibble x10 (would have read back 14_074_320, +270 Hz). + byte[] f = Yaesu2RigConstant.setOperationFreq(14_074_050L); + byte[] expected = {(byte) 0x01, (byte) 0x40, (byte) 0x74, (byte) 0x05, (byte) 0x01}; + assertThat(f).isEqualTo(expected); + } + @Test public void setPTTState_toggleByteDiffers() { byte[] on = Yaesu2RigConstant.setPTTState(true); From 73d15b20103240f21daac57b2403984e94090590 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 03:07:36 +0000 Subject: [PATCH 006/113] iOS: don't upload the RR73 sign-off as a locator to PSKReporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FT8 end-of-QSO roger-73 report "RR73" is a 4-char Maidenhead look-alike (R,R are valid A-R field letters; 7,3 valid digits), so the decoder copies it into a message's `grid` field via `looksLikeGrid`. `PskReporter.makeSpot` gated the reported `senderLocator` on a naive `grid.count >= 4`, so every decoded RR73 was spotted to the global PSKReporter database as locator "RR73" — a phantom Arctic coordinate (83.5N, 175E) that pollutes a shared ecosystem resource and breaks WSJT-X interop (no protocol-compliant transmitter ever means grid RR73). The rest of the Kit already excludes it wherever it classifies a grid (`gridToLatLon` returns nil; `QsoEngine` maps it to the `.rr73` stage) — PSKReporter was the one consumer that missed it. This mirrors the Android fix (PskReporterSender.reportableLocator, PR #612), the iOS side of the same ecosystem-interop defect. Fix: extract a pure `reportableLocator(_:)` helper that requires a >= 4 char locator AND rejects the "RR73" sign-off (case-insensitive), and gate `makeSpot`'s locator on it. Well-formed grids are unaffected. Tests: PskReporterTests gains reportableLocator cases (accepts real grids, rejects short tokens and RR73/rr73/Rr73) and a makeSpot case asserting an RR73-grid decode is still spotted but with a nil locator. Verified red->green (reverting the helper to the old length-only check fails 4 cases). Whole-module `swift test` on Linux is blocked by an unrelated Apple-only `import Network` in WsjtxUdpService.swift; the Foundation-only PskReporter source + its tests were run in isolation via SwiftPM. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/FT8Engine/PskReporter.swift | 25 ++++++++++++-- .../FT8EngineTests/PskReporterTests.swift | 33 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/ios/FT8AFKit/Sources/FT8Engine/PskReporter.swift b/ios/FT8AFKit/Sources/FT8Engine/PskReporter.swift index 327810cc2..788a7a2aa 100644 --- a/ios/FT8AFKit/Sources/FT8Engine/PskReporter.swift +++ b/ios/FT8AFKit/Sources/FT8Engine/PskReporter.swift @@ -41,11 +41,30 @@ public enum PskReporter { return base } + /// A decoded `grid` token is reportable as a PSKReporter `senderLocator` + /// only when it is a real Maidenhead locator: at least the 4-char + /// field/square, and not the "RR73" QSO sign-off. The decoder copies the + /// end-of-QSO roger-73 report into `grid` because it matches the Maidenhead + /// field/square shape (`looksLikeGrid("RR73")` is true — R,R are valid A–R + /// field letters and 7,3 valid digits), but no protocol-compliant + /// transmitter ever means grid RR73: FT8 always packs "RR73" as the report, + /// never as a grid. The rest of the app already excludes it (`gridToLatLon` + /// returns nil, `QsoEngine` classifies it as the `.rr73` stage) — PSKReporter + /// is the one consumer that would otherwise upload a phantom Arctic locator + /// (83.5°N, 175°E) to the shared spot database, breaking WSJT-X interop. + /// Mirrors the Android fix in `PskReporterSender.reportableLocator`. + static func reportableLocator(_ grid: String) -> String? { + guard grid.count >= 4 else { return nil } + if grid.uppercased() == "RR73" { return nil } + return grid + } + /// Convert one decode into a spot, applying the Android sender's policy: /// drop empty/unresolved-hash calls and free text (i3=0, n3=0), strip /// angle brackets from hashed callsigns, never report our own callsign, - /// require a >= 4-char locator (else omit it), and report the RF frequency - /// as dial + audio offset. Returns nil when the decode must not be spotted. + /// require a real >= 4-char Maidenhead locator (else omit it), and report + /// the RF frequency as dial + audio offset. Returns nil when the decode + /// must not be spotted. public static func makeSpot( callFrom: String, i3: UInt8, n3: UInt8, grid: String, snr: Int, audioFreqHz: Float, dialFreqHz: Int64, myCall: String, utcSeconds: Int64 @@ -62,7 +81,7 @@ public enum PskReporter { frequencyHz: dialFreqHz + Int64(audioFreqHz), snr: snr, mode: "FT8", - senderLocator: grid.count >= 4 ? grid : nil, + senderLocator: reportableLocator(grid), flowStartSeconds: utcSeconds ) } diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/PskReporterTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/PskReporterTests.swift index 5f4e78639..7e441d0c8 100644 --- a/ios/FT8AFKit/Tests/FT8EngineTests/PskReporterTests.swift +++ b/ios/FT8AFKit/Tests/FT8EngineTests/PskReporterTests.swift @@ -300,6 +300,39 @@ final class PskReporterTests: XCTestCase { audioFreqHz: 0, dialFreqHz: 0, myCall: "KD2OGR", utcSeconds: 0)) } + // MARK: reportableLocator / RR73 policy + + func testReportableLocatorAcceptsRealGrids() { + XCTAssertEqual(PskReporter.reportableLocator("FN42"), "FN42") + XCTAssertEqual(PskReporter.reportableLocator("IO91wm"), "IO91wm") + } + + func testReportableLocatorRejectsShortTokens() { + XCTAssertNil(PskReporter.reportableLocator("")) + XCTAssertNil(PskReporter.reportableLocator("FN")) + XCTAssertNil(PskReporter.reportableLocator("RR")) // bare sign-off, < 4 chars + } + + func testReportableLocatorRejectsRR73SignOff() { + // "RR73" is the FT8 roger-73 report, never a grid — even though it is a + // 4-char Maidenhead look-alike. Case-insensitive. + XCTAssertNil(PskReporter.reportableLocator("RR73")) + XCTAssertNil(PskReporter.reportableLocator("rr73")) + XCTAssertNil(PskReporter.reportableLocator("Rr73")) + } + + func testMakeSpotDropsRR73Locator() { + // A decode whose grid field carries the RR73 sign-off must still be + // spotted (the call is real), but never with RR73 as the locator. + let s = PskReporter.makeSpot( + callFrom: "K1ABC", i3: 1, n3: 0, grid: "RR73", snr: -3, + audioFreqHz: 1200, dialFreqHz: 14_074_000, myCall: "KD2OGR", + utcSeconds: 1_700_000_000) + XCTAssertEqual(s?.senderCallsign, "K1ABC") + XCTAssertEqual(s?.frequencyHz, 14_075_200) + XCTAssertNil(s?.senderLocator) + } + // MARK: software string func testBuildSoftwareString() { From 14c7495cbaaab39e087986481842486c6067f080 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 04:23:01 +0000 Subject: [PATCH 007/113] Null-check the hamlib JNI bridge's array/string pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hamlib loopback bridge (added in feat/hamlib-cat #516) was the one JNI entry point in the codebase that never adopted the null-checked-pin pattern every other feed uses (see ftx_feed.h). nativeFeedFromRig runs on the serial read thread for every CAT reply while a rig is connected. It (1) called GetArrayLength(data) with no null guard on the jarray (a NULL jarray to GetArrayLength is undefined behaviour) and (2) fed the GetByteArrayElements result straight into write() with no null check — a failed pin (out-of-memory / heap pressure) returns NULL with a pending exception, which was then left unhandled on the CAT read thread. nativeSetMode had the same unchecked-pin issue with GetStringUTFChars before rig_parse_mode(). Fix: - Guard data == null; on a failed byte-array pin, ExceptionClear() and drop the frame instead of dereferencing NULL / returning with a pending exception. - Guard mode == null and a failed string pin in nativeSetMode. - Extract the short-write-tolerant forwarding loop into a pure, host-testable header hamlib_feed.h (hamlib_feed_write), a no-op on NULL / len<=0 / fd<0. Behaviour is byte-identical for every valid CAT frame; the only change is that a null pointer or a failed pin is now a guarded no-op. Testing: - New host test test_hamlib_feed.c (pipe + reader thread): valid feed delivered exactly, a 256 KiB frame drains through short writes with no loss, and NULL / zero-length / negative-length / negative-fd inputs are guarded no-ops. Wired into run_host_tests.sh (the CI host); .ps1 gets a skip-note since hamlib_jni.cpp is POSIX-only (sockets/pthread) and never builds on Windows. - Full host C suite passes (CC=zig cc); :app:externalNativeBuildDebug green across all 4 ABIs; :app:testDebugUnitTest green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/src/main/cpp/ft8af_glue/hamlib_feed.h | 57 +++++++ .../src/main/cpp/ft8af_glue/hamlib_jni.cpp | 26 ++- .../main/cpp/ft8af_glue/run_host_tests.ps1 | 8 + .../src/main/cpp/ft8af_glue/run_host_tests.sh | 14 ++ .../main/cpp/ft8af_glue/test_hamlib_feed.c | 152 ++++++++++++++++++ 5 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 ft8af/app/src/main/cpp/ft8af_glue/hamlib_feed.h create mode 100644 ft8af/app/src/main/cpp/ft8af_glue/test_hamlib_feed.c diff --git a/ft8af/app/src/main/cpp/ft8af_glue/hamlib_feed.h b/ft8af/app/src/main/cpp/ft8af_glue/hamlib_feed.h new file mode 100644 index 000000000..3e563a085 --- /dev/null +++ b/ft8af/app/src/main/cpp/ft8af_glue/hamlib_feed.h @@ -0,0 +1,57 @@ +// hamlib_feed.h — the byte-forwarding step of the hamlib loopback bridge. +// +// WHY THIS EXISTS +// ------------------------------------------------------------------ +// nativeFeedFromRig (hamlib_jni.cpp) pushes every CAT reply the radio sends +// into hamlib's read side by writing the bytes to the accepted loopback socket +// `srv_fd`. It runs on the serial read thread for EVERY reply while a rig is +// connected — a genuinely hot, live path. +// +// The write loop takes a `jbyte *` obtained from JNIEnv::GetByteArrayElements, +// which is allowed to return NULL when the JVM cannot pin the array (out-of- +// memory / heap pressure), leaving a pending exception. The original inline loop +// did not null-check that pin: it fed the NULL pointer straight into write() and +// then returned to Java with the JVM's OOM exception still pending on the CAT +// read thread. Every other JNI pin in this codebase is null-checked (see +// ftx_feed.h); the hamlib bridge, added later, missed the pattern. The matching +// JNI-side fix in hamlib_jni.cpp also guards a NULL `data` jarray (a NULL passed +// to GetArrayLength is itself undefined behaviour) and clears the pending +// exception on a failed pin. +// +// This header collapses the write loop into one null-safe, host-tested +// implementation (test_hamlib_feed.c). It takes plain POSIX types (no JNI +// dependency) so it links with no NDK. The behaviour is identical to the old +// inline loop for every valid buffer; the only change is that a NULL pointer, a +// non-positive length, or a closed socket (fd < 0) is now a no-op instead of a +// crash. + +#ifndef FT8AF_HAMLIB_FEED_H +#define FT8AF_HAMLIB_FEED_H + +#include +#include +#include + +// Write all `len` bytes of `bytes` to socket `fd`, tolerating short writes +// (loops until the whole buffer is sent). A NULL `bytes`, a non-positive `len`, +// or a negative `fd` is a no-op — never a dereference. Stops early if write() +// returns <= 0 (closed/errored socket), mirroring the original loop. +// +// Returns the number of bytes actually written (0 on any of the guarded cases). +static inline long hamlib_feed_write(int fd, const unsigned char *bytes, long len) +{ + if (fd < 0 || bytes == NULL || len <= 0) + return 0; + + long off = 0; + while (off < len) + { + ssize_t w = write(fd, bytes + off, (size_t)(len - off)); + if (w <= 0) + break; + off += w; + } + return off; +} + +#endif // FT8AF_HAMLIB_FEED_H diff --git a/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp b/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp index 1caab1de5..00843b216 100644 --- a/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp +++ b/ft8af/app/src/main/cpp/ft8af_glue/hamlib_jni.cpp @@ -36,6 +36,8 @@ #include +#include "hamlib_feed.h" + #define TAG "HamlibJNI" #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) #define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__) @@ -202,16 +204,19 @@ JNIEXPORT void JNICALL Java_com_k1af_ft8af_wave_HamlibNative_nativeFeedFromRig(JNIEnv *env, jclass, jlong handle, jbyteArray data) { auto *b = handle_to_bridge(handle); - if (b == nullptr || b->srv_fd < 0) return; + if (b == nullptr || data == nullptr || b->srv_fd < 0) return; jsize len = env->GetArrayLength(data); if (len <= 0) return; jbyte *bytes = env->GetByteArrayElements(data, nullptr); - ssize_t off = 0; - while (off < len) { - ssize_t w = write(b->srv_fd, bytes + off, (size_t) (len - off)); - if (w <= 0) break; - off += w; + if (bytes == nullptr) { + // The pin failed (OOM / heap pressure) and left a pending exception. + // Clear it so the serial read thread's next JNI call isn't poisoned, + // and drop this frame instead of dereferencing NULL in write(). + env->ExceptionClear(); + return; } + hamlib_feed_write(b->srv_fd.load(), + reinterpret_cast(bytes), (long) len); env->ReleaseByteArrayElements(data, bytes, JNI_ABORT); } @@ -238,8 +243,15 @@ JNIEXPORT jint JNICALL Java_com_k1af_ft8af_wave_HamlibNative_nativeSetMode(JNIEnv *env, jclass, jlong handle, jstring mode) { auto *b = handle_to_bridge(handle); - if (b == nullptr || b->rig == nullptr) return -1; + if (b == nullptr || b->rig == nullptr || mode == nullptr) return -1; const char *s = env->GetStringUTFChars(mode, nullptr); + if (s == nullptr) { + // GetStringUTFChars can return NULL (with a pending exception) when the + // JVM cannot allocate the UTF-8 copy. Clear it and bail rather than pass + // NULL to rig_parse_mode(). + env->ExceptionClear(); + return -1; + } rmode_t m = rig_parse_mode(s); env->ReleaseStringUTFChars(mode, s); if (m == RIG_MODE_NONE) return -2; diff --git a/ft8af/app/src/main/cpp/ft8af_glue/run_host_tests.ps1 b/ft8af/app/src/main/cpp/ft8af_glue/run_host_tests.ps1 index 2b5901696..dcb8cac51 100644 --- a/ft8af/app/src/main/cpp/ft8af_glue/run_host_tests.ps1 +++ b/ft8af/app/src/main/cpp/ft8af_glue/run_host_tests.ps1 @@ -359,6 +359,14 @@ if ($LASTEXITCODE -ne 0) { Write-Error "Compile failed (test_feed)." } & $outFeed $feedExit = $LASTEXITCODE +# --------------------------------------------------------------------------- +# NOTE: test_hamlib_feed.c (hamlib_feed.h — the nativeFeedFromRig write loop) is +# intentionally NOT built here. Both it and the code it covers are POSIX-only: +# hamlib_jni.cpp uses BSD sockets + pthreads and only compiles for Android +# (bionic), and the test drives a real pipe fd via unistd/pthread. It is built +# and run by run_host_tests.sh (Linux/macOS — the CI host). +# --------------------------------------------------------------------------- + if ($goldenExit -ne 0) { exit $goldenExit } if ($dev625Exit -ne 0) { exit $dev625Exit } if ($fftWinExit -ne 0) { exit $fftWinExit } diff --git a/ft8af/app/src/main/cpp/ft8af_glue/run_host_tests.sh b/ft8af/app/src/main/cpp/ft8af_glue/run_host_tests.sh index 7415fbd93..207461dc9 100755 --- a/ft8af/app/src/main/cpp/ft8af_glue/run_host_tests.sh +++ b/ft8af/app/src/main/cpp/ft8af_glue/run_host_tests.sh @@ -334,3 +334,17 @@ feed_out="$tmp/ft8_feed_test" -Wall -Wno-deprecated-non-prototype -Wno-unused-function \ -I "$ft8" -I "$here" "${feed_srcs[@]}" -lm -o "$feed_out" "$feed_out" + +# Hamlib feed tests: the byte-forwarding write loop of the hamlib loopback +# bridge (hamlib_feed.h, used by nativeFeedFromRig). Guards that a large frame +# drains through short socket writes and, crucially, that a NULL pointer (a +# failed JNI array pin on the CAT read thread) is a no-op instead of the native +# SIGSEGV the old inline loop took. Header-only, no ft8_lib deps; needs pthread. +hamlib_feed_srcs=( + "$here/test_hamlib_feed.c" +) +hamlib_feed_out="$tmp/ft8_hamlib_feed_test" +"$CC" -std=c11 -O2 -D_GNU_SOURCE \ + -Wall -Wno-deprecated-non-prototype -Wno-unused-function \ + -I "$here" "${hamlib_feed_srcs[@]}" -lpthread -o "$hamlib_feed_out" +"$hamlib_feed_out" diff --git a/ft8af/app/src/main/cpp/ft8af_glue/test_hamlib_feed.c b/ft8af/app/src/main/cpp/ft8af_glue/test_hamlib_feed.c new file mode 100644 index 000000000..8beaf2a70 --- /dev/null +++ b/ft8af/app/src/main/cpp/ft8af_glue/test_hamlib_feed.c @@ -0,0 +1,152 @@ +// Host unit tests for hamlib_feed.h — the byte-forwarding step of the hamlib +// loopback bridge (nativeFeedFromRig). Run by run_host_tests.sh / .ps1. +// +// Covered: +// 1. A valid buffer is written to the socket in full and the byte count is +// returned; the reader gets exactly those bytes. +// 2. A large buffer that needs several write() calls (short writes on a small +// pipe buffer) is still delivered in full — the loop drains it. +// 3. REGRESSION (the reason this file exists): a NULL `bytes` pointer — the +// value JNIEnv::GetByteArrayElements returns when the JVM cannot pin the +// array under memory pressure — must be a guarded no-op that returns 0, not +// a wild write() through a bad pointer. The previous inline loop in +// hamlib_jni.cpp fed the pin straight into write() with no null check (and +// left the JVM's pending OOM exception unhandled on the CAT read thread). +// This test passes NULL and can only pass if the pointer is guarded first. +// 4. A non-positive length is a no-op returning 0. +// 5. A negative fd (closed socket) is a no-op returning 0 — no write attempt. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "hamlib_feed.h" + +static int g_failures = 0; + +static void check(bool ok, const char* what) +{ + printf(" %s %s\n", ok ? "ok: " : "FAIL:", what); + if (!ok) + g_failures++; +} + +// Drain-reader: a pipe's kernel buffer is finite, so a large write would block +// once it fills. Read the pipe on a helper thread so hamlib_feed_write's write() +// loop can make progress through short writes. +struct reader_arg { + int fd; + unsigned char* buf; + long cap; + long got; +}; + +static void* reader_main(void* arg) +{ + struct reader_arg* r = (struct reader_arg*)arg; + for (;;) + { + ssize_t n = read(r->fd, r->buf + r->got, (size_t)(r->cap - r->got)); + if (n <= 0) + break; // EOF (write end closed) or error + r->got += n; + if (r->got >= r->cap) + break; + } + return NULL; +} + +// Write `len` bytes through hamlib_feed_write and collect what the reader saw. +// Returns the value hamlib_feed_write returned; fills *out_got with bytes read. +static long feed_and_collect(const unsigned char* data, long len, + unsigned char* out, long out_cap, long* out_got) +{ + int fds[2]; + if (pipe(fds) != 0) + { + perror("pipe"); + exit(2); + } + struct reader_arg r = { fds[0], out, out_cap, 0 }; + pthread_t th; + pthread_create(&th, NULL, reader_main, &r); + + long wrote = hamlib_feed_write(fds[1], data, len); + + close(fds[1]); // signal EOF to the reader + pthread_join(th, NULL); + close(fds[0]); + *out_got = r.got; + return wrote; +} + +int main(void) +{ + // 1. Valid small buffer: written in full, delivered exactly. + { + const unsigned char msg[] = { 0xFE, 0xFE, 0x00, 0x03, 0xFD }; // Icom-ish frame + unsigned char got[16]; + long ngot = 0; + long wrote = feed_and_collect(msg, (long)sizeof(msg), got, sizeof(got), &ngot); + check(wrote == (long)sizeof(msg), "valid feed returns the written byte count"); + check(ngot == (long)sizeof(msg) && memcmp(got, msg, sizeof(msg)) == 0, + "valid feed delivers the bytes exactly"); + } + + // 2. Large buffer that forces multiple short writes: still delivered whole. + { + const long big = 256 * 1024; // well over a pipe's default buffer + unsigned char* data = (unsigned char*)malloc((size_t)big); + unsigned char* got = (unsigned char*)malloc((size_t)big); + for (long i = 0; i < big; ++i) + data[i] = (unsigned char)(i & 0xFF); + long ngot = 0; + long wrote = feed_and_collect(data, big, got, big, &ngot); + check(wrote == big, "large feed reports every byte written"); + check(ngot == big && memcmp(got, data, (size_t)big) == 0, + "large feed drains through short writes with no loss"); + free(data); + free(got); + } + + // 3. REGRESSION: NULL bytes must be a guarded no-op, not a SIGSEGV. + { + int fds[2]; + if (pipe(fds) != 0) { perror("pipe"); return 2; } + long wrote = hamlib_feed_write(fds[1], NULL, 5); + check(wrote == 0, "NULL bytes is a no-op (no crash)"); + close(fds[0]); + close(fds[1]); + } + + // 4. Non-positive length: no-op. + { + int fds[2]; + if (pipe(fds) != 0) { perror("pipe"); return 2; } + const unsigned char msg[] = { 1, 2, 3 }; + check(hamlib_feed_write(fds[1], msg, 0) == 0, "zero length is a no-op"); + check(hamlib_feed_write(fds[1], msg, -1) == 0, "negative length is a no-op"); + close(fds[0]); + close(fds[1]); + } + + // 5. Negative fd (closed socket): no-op, no write attempt. + { + const unsigned char msg[] = { 1, 2, 3 }; + check(hamlib_feed_write(-1, msg, 3) == 0, "negative fd is a no-op"); + } + + if (g_failures == 0) + { + printf("test_hamlib_feed: ALL PASS\n"); + return 0; + } + printf("test_hamlib_feed: %d FAILURE(S)\n", g_failures); + return 1; +} From 9bd3ab9281fc6851360b797d7f4df17aa0f3eab5 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 05:25:37 +0000 Subject: [PATCH 008/113] Clamp Maidenhead grid indices so a pole/antimeridian GPS fix can't emit an invalid locator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getGridSquare() converted latitude/longitude to a Maidenhead locator by truncating each field/digit index with no upper bound. At the North Pole (lat == 90) the latitude field index reached 18 — one past the legal A-R range — emitting the letter 'S', e.g. the invalid locator "JS09". The same overflow exists on the +180 antimeridian in the raw longitude math (Play Services LatLng normalizes 180 -> -180, so that axis is only reachable via the extracted function, but it is defended too). This grid is written to config as the operator's own grid, transmitted in FT8 messages, and uploaded to PSKReporter, so a pole fix polluted the QSO and the shared spot database with a non-existent locator. Fix: clamp each field/digit/subsquare index to its legal Maidenhead range, folding the boundary onto the northernmost/easternmost cell (field R) — the standard convention. The clamp is a no-op for every in-range coordinate (which never reaches a clamp ceiling), so ordinary fixes are byte-identical. This mirrors the defensive clamps already used in this class (gridToLatLng's +-85 map clamp, greatCircleDistanceKm's acos domain clamp). The lat/lon -> grid math is extracted into a pure static gridSquareFor(lat, lon) so the boundary behavior is unit-testable without a Play-Services LatLng. Adds a Robolectric North-Pole regression test (fails pre-fix) plus a pure-JVM MaidenheadGridSquareTest covering the antimeridian, poles, a global sweep, out-of-range inputs, and byte-identical output for known locations. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../k1af/ft8af/maidenhead/MaidenheadGrid.java | 52 +++++++++-- .../maidenhead/MaidenheadGridSquareTest.java | 92 +++++++++++++++++++ .../ft8af/maidenhead/MaidenheadGridTest.java | 16 ++++ 3 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/maidenhead/MaidenheadGridSquareTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/maidenhead/MaidenheadGrid.java b/ft8af/app/src/main/java/com/k1af/ft8af/maidenhead/MaidenheadGrid.java index 625a55a40..58fb75a60 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/maidenhead/MaidenheadGrid.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/maidenhead/MaidenheadGrid.java @@ -217,10 +217,34 @@ public static LatLng[] gridToPolygon(String grid) { * @return String Maidenhead grid string */ public static String getGridSquare(LatLng location) { + return gridSquareFor(location.latitude, location.longitude); + } + + /** + * Latitude/longitude (decimal degrees) -> 4-character Maidenhead grid. Extracted from + * {@link #getGridSquare(LatLng)} so the boundary math is unit-testable without a + * Play-Services {@code LatLng} (which additionally clamps latitude to [-90, 90] and + * normalizes longitude to [-180, 180)). + * + *

Each field/digit/subsquare index is clamped to its legal Maidenhead range via + * {@link #clampIndex}. Without the clamp, a fix on the {@code +180} antimeridian + * ({@code lon == 180}) or the North Pole ({@code lat == 90}) drove the first-pair index + * to 18 — one past the legal A-R field range — emitting the letter {@code 'S'}, i.e. an + * invalid locator such as {@code "JS09"}. That grid is written to config as the + * operator's own grid, transmitted in FT8 messages and uploaded to PSKReporter, so a + * pole/antimeridian fix polluted the QSO and the shared spot database with a + * non-existent locator. Clamping folds the boundary onto the northernmost/easternmost + * cell (field {@code R}) — the standard Maidenhead convention — and is a no-op for every + * in-range coordinate (which never reaches a clamp ceiling), so ordinary fixes are + * byte-identical. This mirrors the defensive clamps already used elsewhere in this class + * ({@link #gridToLatLng}'s ±85° map clamp, {@link #greatCircleDistanceKm}'s acos domain + * clamp). + */ + static String gridSquareFor(double lat, double lon) { double tempNumber;//For intermediate calculation int index;//Determines the character to display - double _long = location.longitude; - double _lat = location.latitude; + double _long = lon; + double _lat = lat; StringBuilder buff = new StringBuilder(); /* @@ -228,13 +252,13 @@ public static String getGridSquare(LatLng location) { */ _long += 180; // Start from the middle of the Pacific tempNumber = _long / 20; // Each major square is 20 degrees wide - index = (int) tempNumber; // Index for uppercase letter + index = clampIndex((int) tempNumber, 17); // Field letters A-R (0..17) buff.append(String.valueOf((char) (index + 'A'))); // Set the first character _long = _long - (index * 20); // Remainder for step 2 _lat += 90; // Start from the South Pole, 180 degrees tempNumber = _lat / 10; // Each major square is 10 degrees tall - index = (int) tempNumber; // Index for uppercase letter + index = clampIndex((int) tempNumber, 17); // Field letters A-R (0..17) buff.append(String.valueOf((char) (index + 'A')));//Set the second character _lat = _lat - (index * 10); // Remainder for step 2 @@ -242,12 +266,12 @@ public static String getGridSquare(LatLng location) { * Now the second pair of two digits: */ tempNumber = _long / 2; // Remainder from step 1 divided by 2 - index = (int) tempNumber; // Digit index + index = clampIndex((int) tempNumber, 9); // Square digits 0-9 buff.append(String.valueOf((char) (index + '0')));//Set the third character _long = _long - (index * 2); // Remainder for step 3 tempNumber = _lat; // Remainder from step 1 divided by 1 - index = (int) tempNumber; // Digit index + index = clampIndex((int) tempNumber, 9); // Square digits 0-9 buff.append(String.valueOf((char) (index + '0')));//Set the fourth character _lat = _lat - index; // Remainder for step 3 @@ -255,16 +279,28 @@ public static String getGridSquare(LatLng location) { * Now the third pair of two lowercase characters: */ tempNumber = _long / 0.083333; // Remainder from step 2 divided by 0.083333 - index = (int) tempNumber; // Index for lowercase letter + index = clampIndex((int) tempNumber, 23); // Subsquare letters a-x (0..23) buff.append(String.valueOf((char) (index + 'a')));//Set the fifth character tempNumber = _lat / 0.0416665; // Remainder from step 2 divided by 0.0416665 - index = (int) tempNumber; // Index for lowercase letter + index = clampIndex((int) tempNumber, 23); // Subsquare letters a-x (0..23) buff.append(String.valueOf((char) (index + 'a')));//Set the sixth character return buff.toString().substring(0, 4); } + /** + * Clamp a Maidenhead character index to {@code [0, max]}. A pole/antimeridian fix (or an + * out-of-range coordinate) would otherwise index one past the pair's legal range and emit + * a character outside it (e.g. {@code 'S'} for the A-R field letters). For every in-range + * coordinate the index already lands inside {@code [0, max]}, so this is a no-op there. + */ + private static int clampIndex(int value, int max) { + if (value < 0) return 0; + if (value > max) return max; + return value; + } + /** * Calculate distance between two latitude/longitude points * diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/maidenhead/MaidenheadGridSquareTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/maidenhead/MaidenheadGridSquareTest.java new file mode 100644 index 000000000..7948a205a --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/maidenhead/MaidenheadGridSquareTest.java @@ -0,0 +1,92 @@ +package com.k1af.ft8af.maidenhead; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Pure-JVM tests for {@link MaidenheadGrid#gridSquareFor(double, double)} — the raw + * latitude/longitude -> Maidenhead grid math, extracted from + * {@link MaidenheadGrid#getGridSquare} so the boundary clamps can be exercised without a + * Play-Services {@code LatLng} (which clamps latitude and normalizes longitude before the + * math ever runs, hiding the +180 antimeridian case). No Robolectric runner needed: the + * method touches no Android types. + */ +public class MaidenheadGridSquareTest { + + // ---------- normal coordinates are byte-identical to the historical output ---------- + + @Test + public void gridSquareFor_knownLocationsUnchanged() { + // Boston ~ FN42, London ~ IO91, Tokyo ~ PM95, Sydney ~ QF56. These reach no clamp + // ceiling, so the clamped path must be identical to the pre-fix output. + assertThat(MaidenheadGrid.gridSquareFor(42.5, -71.0)).isEqualTo("FN42"); + assertThat(MaidenheadGrid.gridSquareFor(51.5, -0.1)).isEqualTo("IO91"); + assertThat(MaidenheadGrid.gridSquareFor(35.68, 139.77)).isEqualTo("PM95"); + assertThat(MaidenheadGrid.gridSquareFor(-33.87, 151.21)).isEqualTo("QF56"); + } + + @Test + public void gridSquareFor_producesValidLocatorAcrossTheGlobe() { + // Sweep a coarse grid of coordinates; every result must be a structurally valid + // 4-character Maidenhead locator (fields A-R, digits 0-9). Asserted on the raw + // character ranges rather than checkMaidenhead, which additionally rejects the + // "RR73" sign-off token even though it is a geometrically valid locator. + for (double lat = -89; lat <= 89; lat += 7.5) { + for (double lon = -179; lon <= 179; lon += 7.5) { + assertValidLocator(MaidenheadGrid.gridSquareFor(lat, lon)); + } + } + } + + // ---------- boundary / out-of-range coordinates stay valid (the fix) ---------- + + @Test + public void gridSquareFor_plus180AntimeridianClampsToFieldR() { + // lon == 180 pushed the longitude field index to 18 -> 'S' (one past A-R). + // It must fold onto the easternmost field 'R'. + String grid = MaidenheadGrid.gridSquareFor(0.0, 180.0); + assertThat(MaidenheadGrid.checkMaidenhead(grid)).isTrue(); + assertThat(grid.charAt(0)).isEqualTo('R'); + assertThat(grid.charAt(0)).isAtMost('R'); + } + + @Test + public void gridSquareFor_northPoleClampsToFieldR() { + // lat == 90 pushed the latitude field index to 18 -> 'S'. It must fold onto the + // northernmost field 'R'. + String grid = MaidenheadGrid.gridSquareFor(90.0, 0.0); + assertThat(MaidenheadGrid.checkMaidenhead(grid)).isTrue(); + assertThat(grid.charAt(1)).isEqualTo('R'); + assertThat(grid.charAt(1)).isAtMost('R'); + } + + @Test + public void gridSquareFor_southPoleAndWestAntimeridianAreLowerBound() { + // The low edges were already in range (index 0 -> 'A'); guard against a + // regression from an over-eager clamp. + assertThat(MaidenheadGrid.gridSquareFor(-90.0, -180.0)).isEqualTo("AA00"); + } + + @Test + public void gridSquareFor_outOfRangeCoordinatesStillYieldValidLocator() { + // Defensive: even nonsensical coordinates (garbage fix, uncorrected sensor) must + // never emit a character outside a legal Maidenhead pair. + for (double lat : new double[] {-1000, -90.0001, 90.0001, 1000}) { + for (double lon : new double[] {-1000, -180.0001, 180.0001, 1000}) { + assertValidLocator(MaidenheadGrid.gridSquareFor(lat, lon)); + } + } + } + + /** Assert a 4-char string is a structurally valid Maidenhead locator: fields A-R, digits 0-9. */ + private static void assertValidLocator(String grid) { + assertThat(grid).hasLength(4); + assertThat(grid.charAt(0)).isAtLeast('A'); + assertThat(grid.charAt(0)).isAtMost('R'); + assertThat(grid.charAt(1)).isAtLeast('A'); + assertThat(grid.charAt(1)).isAtMost('R'); + assertThat(Character.isDigit(grid.charAt(2))).isTrue(); + assertThat(Character.isDigit(grid.charAt(3))).isTrue(); + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/maidenhead/MaidenheadGridTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/maidenhead/MaidenheadGridTest.java index 232b105d1..ad72c7f5f 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/maidenhead/MaidenheadGridTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/maidenhead/MaidenheadGridTest.java @@ -127,6 +127,22 @@ public void getGridSquare_roundTripsKnownLocation() { assertThat(grid).isEqualTo("FN42"); } + @Test + public void getGridSquare_northPoleStaysWithinFieldRange() { + // A GPS fix at the North Pole (lat == 90) drove the latitude field index + // to 18 — one past the legal A-R range — emitting the letter 'S', i.e. an + // invalid locator that was then written to config as the operator's grid, + // transmitted in FT8 messages, and uploaded to PSKReporter. (Play-Services + // LatLng clamps latitude to [-90, 90] so 90 survives; it normalizes + // longitude 180 -> -180, so the antimeridian overflow is only reachable in + // the raw math — see MaidenheadGridSquareTest.) The field letters must stay + // in A-R. + String grid = MaidenheadGrid.getGridSquare(new LatLng(90.0, 0.0)); + assertThat(MaidenheadGrid.checkMaidenhead(grid)).isTrue(); + assertThat(grid.charAt(0)).isAtMost('R'); + assertThat(grid.charAt(1)).isAtMost('R'); + } + // ---------- getDist ---------- @Test From a581da4a73ad2cf0f66045912a8ed7f2779af2f1 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 07:21:00 +0000 Subject: [PATCH 009/113] Discard UtcTimer pool submits during teardown to stop a crash on exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause ---------- UtcTimer runs two java.util.Timer tasks: a 10 ms cycle-boundary check (secTask) and a 1 s heartbeat (heartBeatTask). Each tick submits its callback to a cached thread pool via execute(). delete() tears the timer down by cancelling both Timers and then calling shutdownNow() on both pools. Timer.cancel() does not wait for a TimerTask that is already running, so a tick can be mid-run() — about to call pool.execute() — at the instant delete() shuts that pool down from another thread. With the default AbortPolicy, execute() then throws RejectedExecutionException. secTask's catch only handles InterruptedException and heartBeatTask has no catch at all, so the exception escapes TimerTask.run(), terminates the Timer thread, and reaches the process's default uncaught-exception handler — crashing the app. delete() runs on every app exit (ComposeMainActivity.onDestroy) and every FT8/FT4/FT2 mode switch (rebuildTimer), while the 1 s heartbeat is always live, so the race is exercised routinely. Fix --- Build both pools with a ThreadPoolExecutor.DiscardPolicy rejected-execution handler (otherwise identical to Executors.newCachedThreadPool()). A submit that loses the race with shutdown is now silently dropped — the correct behaviour during teardown, since the cycle/heartbeat callback is moot once we are shutting down. This is a no-op in normal operation: with an unbounded maximum pool size over a SynchronousQueue, a submit is never rejected until the pool has been shut down. Testing ------- Added pure-JVM tests to UtcTimerTest: the discarding pool tolerates a submit after shutdownNow() (the crashing path), a vanilla cached pool still throws there (documenting the pre-fix bug), and the discarding pool still runs work submitted before shutdown. Full :app:testDebugUnitTest passes (29 UtcTimerTest cases). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/timer/UtcTimer.java | 37 ++++++++++++-- .../com/k1af/ft8af/timer/UtcTimerTest.java | 49 +++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/timer/UtcTimer.java b/ft8af/app/src/main/java/com/k1af/ft8af/timer/UtcTimer.java index 8df157843..1cbc6bb5b 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/timer/UtcTimer.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/timer/UtcTimer.java @@ -28,7 +28,9 @@ import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; public class UtcTimer { @@ -45,14 +47,14 @@ public class UtcTimer { private final Timer secTimer = new Timer(); private final Timer heartBeatTimer = new Timer(); private int time_sec = 0;//time offset - private final ExecutorService cachedThreadPool = Executors.newCachedThreadPool(); + private final ExecutorService cachedThreadPool = newDiscardingCachedThreadPool(); private final Runnable doSomething = new Runnable() { @Override public void run() { onUtcTimer.doOnSecTimer(utc); } }; - private final ExecutorService heartBeatThreadPool = Executors.newCachedThreadPool(); + private final ExecutorService heartBeatThreadPool = newDiscardingCachedThreadPool(); private final Runnable doHeartBeat = new Runnable() { @Override public void run() { @@ -263,6 +265,35 @@ public void delete() { heartBeatThreadPool.shutdownNow(); } + /** + * A cached thread pool that discards a task submitted after it has been shut down instead + * of throwing (the default {@link ThreadPoolExecutor.AbortPolicy}). + * + *

{@link #delete()} shuts these pools down, but {@link Timer#cancel()} does not wait for a + * {@link TimerTask} that is already running. A {@link #secTask()} / {@link #heartBeatTask()} tick + * can therefore be mid-{@code run()} — about to submit its callback to the pool — at the very + * instant {@code delete()} calls {@code shutdownNow()} from another thread. This teardown happens + * on every app exit ({@code ComposeMainActivity.onDestroy}) and every operating-mode switch + * (FT8/FT4/FT2 {@code rebuildTimer}), while the 1 second heartbeat is always live, so the + * window is exercised routinely. + * + *

With the default policy that late submit raises an unchecked + * {@code java.util.concurrent.RejectedExecutionException} out of {@code TimerTask.run()}; the + * {@code secTask} catch only handles {@code InterruptedException} and {@code heartBeatTask} has no + * catch at all, so the exception escapes the {@code Timer}'s thread — which terminates the timer + * and reaches the process's default uncaught-exception handler, crashing the app. Discarding the + * submit is the correct behaviour during teardown (the cycle/heartbeat callback is moot once we + * are shutting down) and is a no-op in normal operation: with an unbounded maximum pool size over + * a {@link SynchronousQueue}, a submit is only ever rejected once the pool has been shut down. + */ + static ThreadPoolExecutor newDiscardingCachedThreadPool() { + // Mirrors Executors.newCachedThreadPool() exactly, then swaps in DiscardPolicy. + ThreadPoolExecutor pool = new ThreadPoolExecutor( + 0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue()); + pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); + return pool; + } + /** * Set the time offset; positive values shift forward * diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/timer/UtcTimerTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/timer/UtcTimerTest.java index df8bb9287..e19d7129b 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/timer/UtcTimerTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/timer/UtcTimerTest.java @@ -1,9 +1,15 @@ package com.k1af.ft8af.timer; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; import org.junit.Test; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; + /** * Static time-formatting helpers on UtcTimer. Inputs are epoch milliseconds and * every formatter is anchored to GMT (the SimpleDateFormat-based ones set the @@ -298,6 +304,49 @@ public void ntpClockOffsetMs_preservesCycleAlignmentLikeTheOldTruncation() { } } + // ------------------------------------------------------------------ + // Teardown race: delete() shuts down the heartbeat/cycle thread pools, + // but Timer.cancel() does not wait for a TimerTask already running, so a + // tick can submit to the pool the instant it is shut down. The default + // AbortPolicy turns that into an uncaught RejectedExecutionException on + // the Timer thread -> process crash. newDiscardingCachedThreadPool() + // discards the late submit instead. (Every app exit and every FT8/FT4/FT2 + // mode switch calls delete() while the 1s heartbeat is live.) + // ------------------------------------------------------------------ + + @Test + public void discardingPool_doesNotThrowWhenSubmittingAfterShutdown() { + ThreadPoolExecutor pool = UtcTimer.newDiscardingCachedThreadPool(); + pool.shutdownNow(); + // This is exactly what secTask()/heartBeatTask() do (pool.execute(runnable)) + // when they lose the race with delete(): it must NOT throw. + pool.execute(() -> { }); + } + + @Test + public void defaultCachedPool_throwsWhenSubmittingAfterShutdown_documentingTheBug() { + // The pre-fix pool (Executors.newCachedThreadPool()) uses AbortPolicy, so the + // same submit-after-shutdown that the fix tolerates would throw here — the + // exception that escaped the TimerTask and crashed the app. + ExecutorService legacy = Executors.newCachedThreadPool(); + legacy.shutdownNow(); + assertThrows(RejectedExecutionException.class, () -> legacy.execute(() -> { })); + } + + @Test + public void discardingPool_stillRunsSubmittedWorkBeforeShutdown() throws Exception { + // In normal operation the discarding pool behaves like a cached pool: work + // submitted before shutdown runs. Only post-shutdown submits are dropped. + ThreadPoolExecutor pool = UtcTimer.newDiscardingCachedThreadPool(); + try { + final java.util.concurrent.CountDownLatch ran = new java.util.concurrent.CountDownLatch(1); + pool.execute(ran::countDown); + assertThat(ran.await(5, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + } finally { + pool.shutdownNow(); + } + } + @Test public void ntpClockOffsetMs_clampsAbsurdErrorsToIntRange() { // A wildly wrong device clock (> ~24.8 days off) must not overflow the int From 3dea108cbe59bfba2b32672b4c4a1b48d4c24910 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 08:15:33 +0000 Subject: [PATCH 010/113] Make the QRZ avatar cache thread-safe (fix Settings-clear vs lookup race) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The QRZ image clients (QrzWebClient, QrzXmlClient) cached lookups in an access-ordered LinkedHashMap. Reads ran on Dispatchers.IO under a coroutine mutex, but clearCache() — invoked from the Settings screen on the main thread when the user saves QRZ credentials — called cache.clear() with NO lock. An access-ordered LinkedHashMap re-links its internal list on every get(), so an unlocked clear() racing a locked get() is a data race on a non-thread-safe collection: it can corrupt the map and, in the worst case, throw from Map internals. The avatar LaunchedEffect that drives the lookups per decode row has no try/catch, so an escaping throwable would take down the coroutine. Fix: extract a small internal thread-safe LruCache backed by the same access-ordered LinkedHashMap, with every get/put/clear @Synchronized on one monitor, and use it from both clients. This also removes the duplicated manual eviction loop (now handled by removeEldestEntry) and drops the now-unused coroutine mutex from the web client. Tests: new pure-JVM LruCacheTest covers get/miss/clear, LRU eviction and access-order protection, a deterministic proof that get() on an access-ordered map is a structural modification (why clear must be synchronized), and a concurrent get/put/clear stress smoke test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kotlin/radio/ks3ckc/ft8af/qrz/LruCache.kt | 41 ++++++ .../radio/ks3ckc/ft8af/qrz/QrzWebClient.kt | 15 +- .../radio/ks3ckc/ft8af/qrz/QrzXmlClient.kt | 19 +-- .../radio/ks3ckc/ft8af/qrz/LruCacheTest.kt | 138 ++++++++++++++++++ 4 files changed, 188 insertions(+), 25 deletions(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/qrz/LruCache.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/qrz/LruCacheTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/qrz/LruCache.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/qrz/LruCache.kt new file mode 100644 index 000000000..3d4eb619b --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/qrz/LruCache.kt @@ -0,0 +1,41 @@ +package radio.ks3ckc.ft8af.qrz + +import androidx.annotation.VisibleForTesting + +/** + * A tiny thread-safe LRU cache backed by an access-ordered [LinkedHashMap]. + * + * The QRZ image clients read this cache from per-decode avatar lookups on + * Dispatchers.IO while the Settings screen [clear]s it from the main thread + * when credentials change. An access-ordered LinkedHashMap re-links its + * internal list on every `get()`, so read and clear MUST be mutually excluded + * — an unlocked clear() racing a get() is a structural-modification data race + * (ConcurrentModificationException / NPE / silent corruption). Every operation + * here is `@Synchronized` on this instance, so the two callers can't collide. + * + * The map is capped at [maxSize] entries via [LinkedHashMap.removeEldestEntry]; + * because it is access-ordered, the evicted entry is the least-recently-used. + */ +internal class LruCache(private val maxSize: Int) { + private val map = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean = + size > maxSize + } + + @Synchronized + fun get(key: K): V? = map[key] + + @Synchronized + fun put(key: K, value: V) { + map[key] = value + } + + @Synchronized + fun clear() { + map.clear() + } + + @VisibleForTesting + @Synchronized + fun size(): Int = map.size +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/qrz/QrzWebClient.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/qrz/QrzWebClient.kt index a2249c160..fbc8b5d59 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/qrz/QrzWebClient.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/qrz/QrzWebClient.kt @@ -3,8 +3,6 @@ package radio.ks3ckc.ft8af.qrz import android.util.Log import com.k1af.ft8af.GeneralVariables import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.File import java.io.FileWriter @@ -34,8 +32,7 @@ object QrzWebClient { private const val USER_AGENT = "Mozilla/5.0 (Linux; Android) ft8af/1.0" private const val CACHE_MAX = 200 - private val cacheMutex = Mutex() - private val cache = LinkedHashMap(16, 0.75f, true) + private val cache = LruCache(CACHE_MAX) private val ogImageRegex = Regex( """ $image") - cacheMutex.withLock { - cache[key] = image - while (cache.size > CACHE_MAX) { - val oldest = cache.entries.iterator().next() - cache.remove(oldest.key) - } - } + cache.put(key, image) image } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/qrz/QrzXmlClient.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/qrz/QrzXmlClient.kt index 1266e15a1..141735155 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/qrz/QrzXmlClient.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/qrz/QrzXmlClient.kt @@ -45,8 +45,7 @@ object QrzXmlClient { private val sessionMutex = Mutex() @Volatile private var sessionKey: String? = null - private val cacheMutex = Mutex() - private val cache = LinkedHashMap(16, 0.75f, true) + private val cache = LruCache(CACHE_MAX) @Volatile var lastError: String? = null private set @@ -73,9 +72,9 @@ object QrzXmlClient { * failed lookups can be retried with the new auth. */ fun clearCache() { - // mutex-free read/clear is safe here: only changes the map identity from the - // perspective of callers; concurrent puts will just produce a slightly old - // view that the next call resolves. + // cache.clear() is synchronized inside LruCache, so this is safe to call + // from the main thread (Settings save) while avatar lookups read the cache + // on Dispatchers.IO. cache.clear() lastError = null } @@ -84,7 +83,7 @@ object QrzXmlClient { val key = callsign.trim().uppercase() if (key.isEmpty()) return@withContext null - cacheMutex.withLock { cache[key] }?.let { return@withContext it } + cache.get(key)?.let { return@withContext it } val user = GeneralVariables.qrzXmlUsername.orEmpty() val pass = GeneralVariables.qrzXmlPassword.orEmpty() @@ -126,13 +125,7 @@ object QrzXmlClient { log("lookup($key) -> $image") val result = QrzLookup(imageUrl = image) - cacheMutex.withLock { - cache[key] = result - while (cache.size > CACHE_MAX) { - val oldest = cache.entries.iterator().next() - cache.remove(oldest.key) - } - } + cache.put(key, result) result } diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/qrz/LruCacheTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/qrz/LruCacheTest.kt new file mode 100644 index 000000000..24141fa4e --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/qrz/LruCacheTest.kt @@ -0,0 +1,138 @@ +package radio.ks3ckc.ft8af.qrz + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Pure-JVM tests for [LruCache], the small thread-safe LRU the QRZ image + * clients share. + * + * The class exists because the QRZ clients previously cleared an + * access-ordered [LinkedHashMap] from the Settings screen (main thread, on + * saving credentials) with NO lock, while per-decode avatar lookups read the + * same map on Dispatchers.IO under a coroutine mutex. An access-ordered + * LinkedHashMap re-links its internal list on every get(), so an unlocked + * clear() racing a get() is a structural-modification data race + * (ConcurrentModificationException / NPE / corruption). LruCache funnels + * every read, write, and clear through a single monitor so the two callers + * can never collide. No Android types are touched, so no Robolectric runner. + */ +class LruCacheTest { + + @Test + fun getReturnsPutValue() { + val cache = LruCache(4) + cache.put("W5XO", "url") + assertThat(cache.get("W5XO")).isEqualTo("url") + } + + @Test + fun getMissReturnsNull() { + val cache = LruCache(4) + assertThat(cache.get("missing")).isNull() + } + + @Test + fun clearEmptiesTheCache() { + val cache = LruCache(4) + cache.put("a", "1") + cache.put("b", "2") + cache.clear() + assertThat(cache.get("a")).isNull() + assertThat(cache.get("b")).isNull() + assertThat(cache.size()).isEqualTo(0) + } + + @Test + fun evictsEldestWhenOverCapacity() { + val cache = LruCache(2) + cache.put("a", 1) + cache.put("b", 2) + cache.put("c", 3) // over capacity: "a" is the least-recently-used, evicted + assertThat(cache.size()).isEqualTo(2) + assertThat(cache.get("a")).isNull() + assertThat(cache.get("b")).isEqualTo(2) + assertThat(cache.get("c")).isEqualTo(3) + } + + @Test + fun accessOrderProtectsRecentlyReadEntryFromEviction() { + // Access-order (LRU) semantics: reading "a" makes it most-recent, so + // the next over-capacity put evicts "b" (now the eldest) instead. + val cache = LruCache(2) + cache.put("a", 1) + cache.put("b", 2) + assertThat(cache.get("a")).isEqualTo(1) // touch "a" + cache.put("c", 3) + assertThat(cache.get("b")).isNull() + assertThat(cache.get("a")).isEqualTo(1) + assertThat(cache.get("c")).isEqualTo(3) + } + + /** + * Documents *why* the cache must be synchronized: an access-ordered + * LinkedHashMap treats get() as a structural modification (it moves the + * touched entry to the tail of its list). This is deterministic, single + * threaded proof that a concurrent unlocked clear() would race a get(). + */ + @Test + fun rawAccessOrderMap_getIsAStructuralModification() { + val raw = LinkedHashMap(16, 0.75f, true) + raw["a"] = 1 + raw["b"] = 2 + raw["c"] = 3 + raw["a"] // touch — reorders in access-order mode + assertThat(raw.keys.toList()).containsExactly("b", "c", "a").inOrder() + } + + /** + * Smoke/stress harness: many reader/writer threads hammer the cache while + * another repeatedly clears it — exactly the Settings-save-vs-avatar-lookup + * collision from production. With every op synchronized this must complete + * with no throwable escaping. (The underlying data race usually manifests as + * silent map corruption rather than a deterministic exception, so this is a + * best-effort regression guard, not a proof of the bug — the deterministic + * proof that get() is a structural modification lives in the test above.) + */ + @Test + fun concurrentGetPutAndClear_neverThrows() { + val cache = LruCache(64) + val threads = 8 + val iterations = 20_000 + val start = CountDownLatch(1) + val stop = AtomicBoolean(false) + val failures = Collections.synchronizedList(mutableListOf()) + + val workers = (0 until threads).map { t -> + Thread { + start.await() + try { + var i = 0 + while (!stop.get() && i < iterations) { + val k = i and 0x7f + if (t % 4 == 0) { + cache.clear() + } else if (i and 1 == 0) { + cache.put(k, i) + } else { + cache.get(k) + } + i++ + } + } catch (th: Throwable) { + failures.add(th) + } + }.also { it.start() } + } + + start.countDown() + workers.forEach { it.join(TimeUnit.SECONDS.toMillis(30)) } + stop.set(true) + + assertThat(failures).isEmpty() + } +} From f56a83dcb40db0141ddb47a47adc066b0dde78d0 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 09:07:59 +0000 Subject: [PATCH 011/113] Fix map range rings so they align with station markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The distance-ring overlay on the azimuthal map was scaling each ring by an extra factor of PI/2. Station markers and the land outlines are placed via azProject(), which projects a point at angular distance c to a normalized radius of c/PI — so the disc edge (screen radius r) corresponds to the antipode, PI * 6371 km away. The range rings, however, multiplied (km / maxKm) * r by PI/2, making every ring ~57% larger than the true great-circle distance it claims to represent. Consequences: - The 2500/5000/10000 km rings sat well outside where a station at that distance actually plots, so the operator could not read a marker's range off the rings. - The 15000 and 20000 km rings landed at ~1.5x the disc radius, entirely outside the visible/clipped disc, so they never rendered at all. Root cause: drawRangeRings used a bespoke maxKm=20015 plus a stray PI/2 factor instead of the same normalization azProject applies to markers. Fix: extract the ring radius into a pure, testable rangeRingRadiusPx(km, r, scale) that mirrors the marker projection exactly (normalized radius = km / MAP_EDGE_KM, MAP_EDGE_KM = PI * 6371), and drop the PI/2 factor. Testing: added three pure-JVM cases to MapProjectionTest — edge distance fills the disc, linearity in distance/scale, and (the regression guard) that a ring lands exactly on top of the azProject-projected marker for the same great-circle distance across four operator/target pairs. All three fail against the old PI/2 code and pass after the fix; full testDebugUnitTest is green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../radio/ks3ckc/ft8af/ui/map/MapScreen.kt | 15 ++++++- .../ks3ckc/ft8af/ui/map/MapProjectionTest.kt | 41 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt index 3d8fbc704..840432071 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt @@ -183,6 +183,18 @@ internal fun azProject(opLat: Double, opLon: Double, lat: Double, lon: Double): ) } +// Great-circle distance (km) to the map disc edge. The antipode sits at angular +// distance PI, so it is PI * 6371 km away; [azProject] projects it to a normalized +// radius of 1 (c / PI), i.e. exactly the disc radius `r`. Range rings must be scaled +// against this same maximum so they line up with the station markers. +internal val MAP_EDGE_KM = PI * 6371.0 + +// Screen radius (px) for a range ring at great-circle distance [km], using the SAME +// factor [azProject] applies to markers (normalized radius = distKm / MAP_EDGE_KM), +// so rings align with the marks instead of ballooning past them. +internal fun rangeRingRadiusPx(km: Double, r: Float, scale: Float): Float = + (km / MAP_EDGE_KM).toFloat() * r * scale + // --------------------------------------------------------------------------- // Map Screen // --------------------------------------------------------------------------- @@ -1395,13 +1407,12 @@ private fun DrawScope.drawAzimuthalLand( } private fun DrawScope.drawRangeRings(cx: Float, cy: Float, r: Float, scale: Float = 1f) { - val maxKm = 20015.0 val rings = listOf(2500, 5000, 10000, 15000, 20000) val ringColor = Color(0x1894A3B8) val dashEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 6f)) for (km in rings) { - val ringR = (km.toFloat() / maxKm.toFloat()) * r * (PI.toFloat() / 2f) * scale + val ringR = rangeRingRadiusPx(km.toDouble(), r, scale) drawCircle( color = ringColor, radius = ringR, diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/MapProjectionTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/MapProjectionTest.kt index 884634240..521cc5189 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/MapProjectionTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/MapProjectionTest.kt @@ -2,6 +2,7 @@ package radio.ks3ckc.ft8af.ui.map import com.google.common.truth.Truth.assertThat import org.junit.Test +import kotlin.math.sqrt /** * Pure unit tests for the map's coordinate math: great-circle distance, the two @@ -52,6 +53,46 @@ class MapProjectionTest { assertThat(p.distKm).isWithin(1.0).of(quarterArcKm) } + @Test + fun rangeRingRadius_edgeDistanceFillsTheDisc() { + // A ring at the antipode distance (MAP_EDGE_KM) must sit exactly on the disc + // edge, i.e. its pixel radius equals r * scale. + val r = 500f + assertThat(rangeRingRadiusPx(MAP_EDGE_KM, r, 1f)).isWithin(1e-3f).of(r) + assertThat(rangeRingRadiusPx(MAP_EDGE_KM, r, 2f)).isWithin(1e-3f).of(1000f) + } + + @Test + fun rangeRingRadius_isLinearInDistanceAndScale() { + val r = 400f + // Half the edge distance -> half the radius; doubling scale doubles it. + assertThat(rangeRingRadiusPx(MAP_EDGE_KM / 2.0, r, 1f)).isWithin(1e-3f).of(r / 2f) + assertThat(rangeRingRadiusPx(MAP_EDGE_KM / 2.0, r, 2f)).isWithin(1e-3f).of(r) + } + + @Test + fun rangeRingRadius_alignsWithProjectedMarker() { + // A station at great-circle distance d is drawn at screen radius + // sqrt(x^2 + y^2) * r * scale after azProject. The range ring for that same + // distance must land on top of the marker, not (as the old PI/2 factor did) + // ~57% further out. This is the regression guard for the range-ring bug. + val r = 640f + val scale = 1.5f + val opLat = 40.0 + val opLon = -75.0 + for (target in listOf( + Pair(0.0, 0.0), + Pair(51.5, -0.13), + Pair(-33.9, 151.2), + Pair(35.7, 139.7), + )) { + val p = azProject(opLat, opLon, target.first, target.second) + val markerRadiusPx = sqrt(p.x * p.x + p.y * p.y) * r * scale + val ringRadiusPx = rangeRingRadiusPx(p.distKm, r, scale) + assertThat(ringRadiusPx).isWithin(1e-2f).of(markerRadiusPx) + } + } + @Test fun computeBearing_dueEastIs90() { assertThat(computeBearing(0.0, 0.0, 0.0, 90.0)).isWithin(1e-6).of(90.0) From 2548e99fc0a9e88e3738b9198f92f46607f4137d Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 10:27:13 +0000 Subject: [PATCH 012/113] Fix truncated asset reads that corrupt the callsign/DXCC/zone databases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled reference-data assets were read with `new byte[inputStream.available()]` followed by a single `inputStream.read(bytes)` whose return value was ignored. Neither is reliable: `available()` is only a hint, and one `read(byte[])` is explicitly permitted to return fewer bytes than requested. Android's `AssetInputStream` decompresses on the fly, so a large compressed asset is delivered in chunks and the lone read keeps only the first chunk, leaving the rest of the buffer as NUL bytes. Impact: - cty.dat (~280 KB) — the callsign->country/CQ-zone/ITU-zone map — is silently truncated, so callsigns past the first chunk resolve to the wrong (or no) country/zone in the decode list, on the map, and in ADIF export. - ituzone.json / cqzone.json / dxcc_list.json (450-740 KB) are parsed as JSON right after the read; a truncated buffer makes `new JSONObject(...)` throw, wiping the entire zone/DXCC table. This is the same class of bug the team already fixed for log import in `LogFileImport.readFully`; these were the remaining copies of the pattern. Added `Streams.readAllBytes(InputStream)`, which drains the stream to EOF in a loop, and routed all eight sites through it (CallsignFileOperation, RigNameList, OperationBand, DatabaseOpr x3, HelpDialog, ClearCacheDataDialog). Behavior is otherwise unchanged (same default-charset decoding). Tests: new StreamsTest covers full-drain of a stream that short-reads one byte per call and one that under-reports available(); a new CallsignFileOperationTest case feeds a chunked stream and asserts every record survives (fails against the old single-read, passes now). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ft8af/callsign/CallsignFileOperation.java | 5 +- .../com/k1af/ft8af/database/DatabaseOpr.java | 10 +- .../k1af/ft8af/database/OperationBand.java | 4 +- .../com/k1af/ft8af/database/RigNameList.java | 4 +- .../k1af/ft8af/ui/ClearCacheDataDialog.java | 4 +- .../java/com/k1af/ft8af/ui/HelpDialog.java | 4 +- .../java/com/k1af/ft8af/util/Streams.java | 48 ++++++++ .../callsign/CallsignFileOperationTest.java | 59 ++++++++++ .../java/com/k1af/ft8af/util/StreamsTest.java | 105 ++++++++++++++++++ 9 files changed, 227 insertions(+), 16 deletions(-) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/util/Streams.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/util/StreamsTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/callsign/CallsignFileOperation.java b/ft8af/app/src/main/java/com/k1af/ft8af/callsign/CallsignFileOperation.java index 3bbf13bba..a359244c3 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/callsign/CallsignFileOperation.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/callsign/CallsignFileOperation.java @@ -8,6 +8,8 @@ import android.content.Context; import android.content.res.AssetManager; +import com.k1af.ft8af.util.Streams; + import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; @@ -53,8 +55,7 @@ public static ArrayList getCallSingInfoFromFile(Context context){ */ public static String[] getLinesFromInputStream(InputStream inputStream, String deLimited) { try { - byte[] bytes = new byte[inputStream.available()]; - inputStream.read(bytes); + byte[] bytes = Streams.readAllBytes(inputStream); return (new String(bytes)).split(deLimited); }catch (IOException e){ return null; diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 44480629c..c2b5998a3 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -37,6 +37,7 @@ import com.k1af.ft8af.log.QSLRecordStr; import com.k1af.ft8af.rigs.BaseRigOperation; import com.k1af.ft8af.timer.UtcTimer; +import com.k1af.ft8af.util.Streams; import com.k1af.ft8af.wave.InputAudioLevel; import org.jetbrains.annotations.Nullable; @@ -609,8 +610,7 @@ public void loadItuDataFromFile(SQLiteDatabase db) { "VALUES(?,?)"; try { inputStream = assetManager.open("ituzone.json"); - byte[] bytes = new byte[inputStream.available()]; - inputStream.read(bytes); + byte[] bytes = Streams.readAllBytes(inputStream); JSONObject jsonObject = new JSONObject(new String(bytes)); JSONArray array = jsonObject.names(); for (int i = 0; i < array.length(); i++) { @@ -635,8 +635,7 @@ public void loadICqZoneDataFromFile(SQLiteDatabase db) { "VALUES(?,?)"; try { inputStream = assetManager.open("cqzone.json"); - byte[] bytes = new byte[inputStream.available()]; - inputStream.read(bytes); + byte[] bytes = Streams.readAllBytes(inputStream); JSONObject jsonObject = new JSONObject(new String(bytes)); JSONArray array = jsonObject.names(); for (int i = 0; i < array.length(); i++) { @@ -660,8 +659,7 @@ public ArrayList loadDxccDataFromFile() { ArrayList dxccObjects = new ArrayList<>(); try { inputStream = assetManager.open("dxcc_list.json"); - byte[] bytes = new byte[inputStream.available()]; - inputStream.read(bytes); + byte[] bytes = Streams.readAllBytes(inputStream); JSONObject jsonObject = new JSONObject(new String(bytes)); JSONArray array = jsonObject.names(); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/OperationBand.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/OperationBand.java index 979bc0402..f90cdd0f3 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/OperationBand.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/OperationBand.java @@ -9,6 +9,7 @@ import com.k1af.ft8af.FT8Common; import com.k1af.ft8af.ModeProfile; import com.k1af.ft8af.rigs.BaseRigOperation; +import com.k1af.ft8af.util.Streams; import java.io.IOException; import java.io.InputStream; @@ -365,8 +366,7 @@ public static String getBandInfo(int index){ */ public static String[] getLinesFromInputStream(InputStream inputStream, String deLimited) { try { - byte[] bytes = new byte[inputStream.available()]; - inputStream.read(bytes); + byte[] bytes = Streams.readAllBytes(inputStream); return (new String(bytes)).split(deLimited); }catch (IOException e){ return null; diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/RigNameList.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/RigNameList.java index aadfddd87..fc5893a56 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/RigNameList.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/RigNameList.java @@ -11,6 +11,7 @@ import com.k1af.ft8af.GeneralVariables; import com.k1af.ft8af.R; +import com.k1af.ft8af.util.Streams; import java.io.IOException; import java.io.InputStream; @@ -98,8 +99,7 @@ public int getIndexByAddress(int addr){ */ public static String[] getLinesFromInputStream(InputStream inputStream, String deLimited) { try { - byte[] bytes = new byte[inputStream.available()]; - inputStream.read(bytes); + byte[] bytes = Streams.readAllBytes(inputStream); return (new String(bytes)).split(deLimited); }catch (IOException e){ return null; diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ClearCacheDataDialog.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ClearCacheDataDialog.java index 5f18ce8e3..70a5a584e 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ClearCacheDataDialog.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ClearCacheDataDialog.java @@ -27,6 +27,7 @@ import com.k1af.ft8af.R; import com.k1af.ft8af.database.DatabaseOpr; import com.k1af.ft8af.database.OnAfterQueryFollowCallsigns; +import com.k1af.ft8af.util.Streams; import java.io.IOException; import java.io.InputStream; @@ -219,8 +220,7 @@ public String getTextFromAssets(String fileName) { AssetManager assetManager = context.getAssets(); try { InputStream inputStream = assetManager.open(fileName); - byte[] bytes = new byte[inputStream.available()]; - inputStream.read(bytes); + byte[] bytes = Streams.readAllBytes(inputStream); inputStream.close(); return new String(bytes); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/HelpDialog.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/HelpDialog.java index a7e421a7d..c9a010920 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/HelpDialog.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/HelpDialog.java @@ -25,6 +25,7 @@ import com.k1af.ft8af.BuildConfig; import com.k1af.ft8af.GeneralVariables; import com.k1af.ft8af.R; +import com.k1af.ft8af.util.Streams; import java.io.IOException; import java.io.InputStream; @@ -161,8 +162,7 @@ public String getTextFromAssets(String fileName) { AssetManager assetManager = context.getAssets(); try { InputStream inputStream = assetManager.open(fileName); - byte[] bytes = new byte[inputStream.available()]; - inputStream.read(bytes); + byte[] bytes = Streams.readAllBytes(inputStream); inputStream.close(); return new String(bytes); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/util/Streams.java b/ft8af/app/src/main/java/com/k1af/ft8af/util/Streams.java new file mode 100644 index 000000000..57cfa8b07 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/util/Streams.java @@ -0,0 +1,48 @@ +package com.k1af.ft8af.util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Small stream helpers. + */ +public final class Streams { + + private Streams() { + } + + /** + * Reads an {@link InputStream} to its end into a byte array. + * + *

The bundled data files (cty.dat, the DXCC/CQ/ITU-zone JSON tables, + * rigaddress.txt, bands.txt, the help texts) used to be read with + * {@code new byte[in.available()]} followed by a single {@code in.read(buf)} + * whose return value was ignored. Neither is reliable: {@code available()} + * is only a hint, and one {@code read(byte[])} is explicitly permitted to + * return fewer bytes than requested. Android's {@code AssetInputStream} + * decompresses on the fly, so for a large compressed asset (cty.dat is + * ~280 KB, the zone tables are 450 KB–740 KB) the lone + * read returns only the first chunk and the tail of the buffer stays as NUL + * bytes. That silently truncated the callsign→country/zone database and + * made the zone/DXCC JSON fail to parse entirely. Draining the stream in a + * loop until EOF (the same fix already applied to log import in + * {@code LogFileImport.readFully}) reads the whole file regardless of how the + * underlying stream chunks it. + * + * @param in the stream to drain (not closed here — the caller owns it) + * @return the full stream contents + * @throws IOException if the underlying read fails + */ + public static byte[] readAllBytes(InputStream in) throws IOException { + // available() is only a sizing hint; guard against a 0/negative estimate. + int hint = Math.max(32, in.available()); + ByteArrayOutputStream out = new ByteArrayOutputStream(hint); + byte[] chunk = new byte[8192]; + int n; + while ((n = in.read(chunk)) != -1) { + out.write(chunk, 0, n); + } + return out.toByteArray(); + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/callsign/CallsignFileOperationTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/callsign/CallsignFileOperationTest.java index 19da96efe..eb81f2338 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/callsign/CallsignFileOperationTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/callsign/CallsignFileOperationTest.java @@ -93,4 +93,63 @@ public void getLinesFromInputStream_singleRecordNoDelimiter() { String[] lines = CallsignFileOperation.getLinesFromInputStream(in, ";"); assertThat(lines).asList().containsExactly("only"); } + + @Test + public void getLinesFromInputStream_readsEveryRecordFromAChunkedStream() { + // Regression for the truncated-asset-read bug: cty.dat is a ~280 KB + // compressed asset, and Android's AssetInputStream delivers it in chunks. + // The old code did new byte[available()] + a single read(), so it kept only + // the first chunk and dropped the tail — losing whole countries from the + // callsign->DXCC/zone map. Feed a stream that yields one byte per read and + // assert every semicolon-separated record still comes back. Under the old + // single-read this returns just "rec0..." (one byte) and fails. + int records = 2000; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < records; i++) { + if (i > 0) { + sb.append(';'); + } + sb.append("rec").append(i); + } + byte[] data = sb.toString().getBytes(StandardCharsets.UTF_8); + InputStream in = new OneBytePerReadStream(data); + + String[] lines = CallsignFileOperation.getLinesFromInputStream(in, ";"); + + assertThat(lines).hasLength(records); + assertThat(lines[0]).isEqualTo("rec0"); + assertThat(lines[records - 1]).isEqualTo("rec" + (records - 1)); + } + + /** Stream that returns at most one byte per bulk read, like a chunking asset stream. */ + private static final class OneBytePerReadStream extends InputStream { + private final byte[] data; + private int pos; + + OneBytePerReadStream(byte[] data) { + this.data = data; + } + + @Override + public int read() { + return pos >= data.length ? -1 : (data[pos++] & 0xff); + } + + @Override + public int read(byte[] b, int off, int len) { + if (pos >= data.length) { + return -1; + } + if (len <= 0) { + return 0; + } + b[off] = data[pos++]; + return 1; + } + + @Override + public int available() { + return data.length - pos; + } + } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/util/StreamsTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/util/StreamsTest.java new file mode 100644 index 000000000..693eef9ef --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/util/StreamsTest.java @@ -0,0 +1,105 @@ +package com.k1af.ft8af.util; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** + * Coverage for {@link Streams#readAllBytes(InputStream)}. + * + *

The bug this replaces sized a buffer from {@code available()} and did a + * single {@code read(byte[])}. Both are unreliable: a lone read may return fewer + * bytes than the buffer length, and {@code available()} is only a hint. Android's + * compressed {@code AssetInputStream} exhibits exactly this — a big asset is + * delivered in chunks. {@link ShortReadInputStream} reproduces that behaviour so + * the fix is exercised without an Android runtime. + */ +public class StreamsTest { + + /** + * An {@link InputStream} that hands back at most {@code maxPerRead} bytes per + * {@code read(byte[], int, int)} call and can report an arbitrary (wrong) + * {@code available()}, mirroring a decompressing asset stream. + */ + private static final class ShortReadInputStream extends InputStream { + private final byte[] data; + private final int maxPerRead; + private final int reportedAvailable; + private int pos; + + ShortReadInputStream(byte[] data, int maxPerRead, int reportedAvailable) { + this.data = data; + this.maxPerRead = maxPerRead; + this.reportedAvailable = reportedAvailable; + } + + @Override + public int read() { + if (pos >= data.length) { + return -1; + } + return data[pos++] & 0xff; + } + + @Override + public int read(byte[] b, int off, int len) { + if (pos >= data.length) { + return -1; + } + int n = Math.min(Math.min(len, maxPerRead), data.length - pos); + System.arraycopy(data, pos, b, off, n); + pos += n; + return n; + } + + @Override + public int available() { + return reportedAvailable; + } + } + + @Test + public void readAllBytes_returnsExactContentOfPlainStream() throws IOException { + byte[] src = "hello world".getBytes(StandardCharsets.UTF_8); + byte[] out = Streams.readAllBytes(new ByteArrayInputStream(src)); + assertThat(out).isEqualTo(src); + } + + @Test + public void readAllBytes_emptyStreamReturnsEmptyArray() throws IOException { + byte[] out = Streams.readAllBytes(new ByteArrayInputStream(new byte[0])); + assertThat(out).hasLength(0); + } + + @Test + public void readAllBytes_drainsStreamThatShortReadsOneBytePerCall() throws IOException { + // 100 KB is larger than the 8 KB working chunk and, more importantly, the + // stream only yields one byte per read — the exact case the old + // single-read code truncated. The loop must reassemble every byte. + byte[] src = new byte[100_000]; + for (int i = 0; i < src.length; i++) { + src[i] = (byte) (i * 31 + 7); + } + byte[] out = Streams.readAllBytes( + new ShortReadInputStream(src, 1, src.length)); + assertThat(out).isEqualTo(src); + } + + @Test + public void readAllBytes_ignoresUnderReportedAvailable() throws IOException { + // available() lies (says 4) but the stream holds far more. Because we drain + // to EOF rather than trusting available(), the full payload comes back. + byte[] src = new byte[20_000]; + for (int i = 0; i < src.length; i++) { + src[i] = (byte) (i & 0x7f); + } + byte[] out = Streams.readAllBytes( + new ShortReadInputStream(src, 4096, 4)); + assertThat(out).isEqualTo(src); + } +} From 9cd4e533e2b0d7f1ae5065832fe3a3ca3763d6c6 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 11:07:49 +0000 Subject: [PATCH 013/113] Desktop: stop auto-keying TX after the decoder is stopped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #586 added a per-tick boundary trigger (boundary_tx_ready) that keys a queued reply/CQ early in its slot, independent of decode arrival, so a fast decode no longer drops the reply. Unlike its sibling per-tick actions (the decode trigger and the waterfall row), it was not gated on self.decoding. StopDecode sets decoding=false and resets pending_decodes to 0 (opening the awaiting gate) but leaves qso.active and tx_parity intact. So a CQ/QSO left active when the operator stops decoding kept firing the boundary trigger every eligible slot: the rig transmitted unattended, with the receiver off, and the QSO could never complete (no decodes to advance it). Reachable from the UI — 'Call CQ' has no disabled guard and the decode toggle is independent of TX state. Before #586, maybe_transmit was only reached via handle_decoded, so StopDecode implicitly halted auto-TX; this is a regression. Root-cause fix: gate boundary_tx_ready on decoding, matching the sibling triggers. Byte-identical whenever decoding is on (the normal path); only the decoder-off case changes, restoring the pre-#586 behavior. An in-flight transmission still finalizes (PTT drops) via the existing tx_playback poll. Added boundary_tx_is_dormant_while_decoding_is_stopped and threaded the new decoding arg through the existing boundary_tx_ready test. Verified the pure-function logic under rustc (fails pre-fix, passes after). The full Tauri crate can't link in-sandbox (libdbus-sys build script needs system libdbus); desktop CI compiles and runs these tests via cargo llvm-cov. Co-Authored-By: Claude Opus 4.8 (1M context) --- desktop/src-tauri/src/engine.rs | 45 +++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index f784599f4..999298cc8 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -111,7 +111,16 @@ fn awaiting_any_decode(pending_decodes: usize) -> bool { /// the run loop ticks fast, so the first eligible tick after the decodes settle is /// near the top of the slot — but a reply that becomes eligible a little later in /// the window is still keyed rather than dropped. +/// +/// The trigger is part of the decode-driven RX/TX cycle, so it is dormant while the +/// decoder is stopped (`decoding == false`) — matching the sibling per-tick actions +/// (the decode trigger and the waterfall row, both already gated on `self.decoding`). +/// Without this a QSO/CQ left `active` when the operator stops decoding would keep +/// auto-keying the rig every eligible slot with the receiver off — the radio would +/// transmit unattended, since `pending_decodes` drops to 0 on `StopDecode` so the +/// awaiting gate no longer holds it back. fn boundary_tx_ready( + decoding: bool, active: bool, awaiting_decode: bool, tx_parity: Option, @@ -119,7 +128,9 @@ fn boundary_tx_ready( txed_slot: i64, into_cycle_ms: i64, ) -> bool { - !awaiting_decode && tx_slot_eligible(active, tx_parity, slot_id, txed_slot, into_cycle_ms) + decoding + && !awaiting_decode + && tx_slot_eligible(active, tx_parity, slot_id, txed_slot, into_cycle_ms) } // Live-waterfall FFT parameters (window/size/averaging + display constants) // live in `crate::wf` and are runtime-configurable via SetWaterfallConfig. @@ -632,6 +643,7 @@ impl Engine { // are in; `maybe_transmit` re-checks eligibility and is idempotent per // slot (the `txed_slot` guard). if boundary_tx_ready( + self.decoding, self.qso.active, awaiting_any_decode(self.pending_decodes), self.tx_parity, @@ -1368,17 +1380,34 @@ mod tests { // Fast decode: this slot's decodes are already processed (awaiting=false), so a // reply computed in the previous cycle keys at the top of its slot. This is the // case the decode-arrival trigger used to miss — it fires mid-slot, past the - // window, so the queued reply/CQ was silently dropped. - assert!(boundary_tx_ready(true, false, None, 4, -1, 0)); + // window, so the queued reply/CQ was silently dropped. (First arg is `decoding`, + // true throughout here — the normal case.) + assert!(boundary_tx_ready(true, true, false, None, 4, -1, 0)); // Slow decode still pending: must NOT key a (stale) message; handle_decoded // keys the fresh one when the decode lands. - assert!(!boundary_tx_ready(true, true, None, 4, -1, 0)); + assert!(!boundary_tx_ready(true, true, true, None, 4, -1, 0)); // Past the window, already transmitted this slot, wrong parity, or inactive all // block it too (it delegates to tx_slot_eligible). - assert!(!boundary_tx_ready(true, false, None, 4, -1, TX_LATEST_MS + 1)); - assert!(!boundary_tx_ready(true, false, None, 4, 4, 0)); - assert!(!boundary_tx_ready(true, false, Some(1), 4, -1, 0)); // wants odd, slot 4 even - assert!(!boundary_tx_ready(false, false, None, 4, -1, 0)); + assert!(!boundary_tx_ready(true, true, false, None, 4, -1, TX_LATEST_MS + 1)); + assert!(!boundary_tx_ready(true, true, false, None, 4, 4, 0)); + assert!(!boundary_tx_ready(true, true, false, Some(1), 4, -1, 0)); // wants odd, slot 4 even + assert!(!boundary_tx_ready(true, false, false, None, 4, -1, 0)); + } + + #[test] + fn boundary_tx_is_dormant_while_decoding_is_stopped() { + use super::boundary_tx_ready; + // Regression for the StopDecode runaway-TX bug: after the operator stops + // decoding, `pending_decodes` is reset to 0 (so the awaiting gate opens) but a + // CQ/QSO left `active` keeps `qso.active` and `tx_parity` set. The per-tick + // boundary trigger must stay dormant with the decoder off — otherwise the rig + // keys every eligible slot with the receiver muted (transmitting unattended). + // Every other input here is exactly the "would key" case from the test above; + // only `decoding == false` must hold it back. + assert!(!boundary_tx_ready(false, true, false, None, 4, -1, 0)); + assert!(!boundary_tx_ready(false, true, false, Some(0), 4, -1, 0)); + // Turning decoding back on restores keying (the fix changes nothing else). + assert!(boundary_tx_ready(true, true, false, None, 4, -1, 0)); } #[test] From 44578308b327caab7801a14c822ae3f39b7ee09c Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 13:17:51 +0000 Subject: [PATCH 014/113] Guard the web-logbook upload handler against a missing file1 part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The always-on web logbook's /IMPORTLOGDATA handler read the multipart upload as `files.get("file1").hashCode()`. NanoHTTPD's parseBody() only adds a "file1" entry when the POST/PUT actually carries that file field, so a malformed or non-form request — or a bare LAN probe of the logbook port — left the map without the key and the unconditional deref threw a NullPointerException. That throw escaped serve() before its try/catch (the IMPORTLOGDATA branch runs in the leading dispatch chain, ahead of the try at the bottom of serve), so NanoHTTPD's worker aborted the request with no useful response. The sibling handlers in this same file were already hardened against missing path segments (uriSegment, PR #584) and malformed pagination params (parseQueryInt/clampPageIndex, PR #585); this closes the matching gap for the upload part. Fix: extract a bounds-safe `uploadedFilePath(Map)` helper (null when the part is absent) mirroring the existing static guards, and reject a null result with the existing html_illegal_command page — the same fallback a non-POST method already returns — instead of dereferencing it. Well-formed uploads are byte-identical. Adds LogHttpServerUploadPartTest (pure-JVM, no Robolectric, mirroring LogHttpServerUriSegmentTest / LogHttpServerQueryParamTest). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/html/LogHttpServer.java | 27 +++++++- .../html/LogHttpServerUploadPartTest.java | 66 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/html/LogHttpServerUploadPartTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java b/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java index c2d8bf7bb..4d5648e1a 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java @@ -162,6 +162,26 @@ static String downloadFileName(String month) { return String.format("log%s.adi", month != null ? month : ""); } + /** + * The temp-file path NanoHTTPD stored for the {@code file1} upload part of a POST/PUT + * to {@code /IMPORTLOGDATA}, or {@code null} when the request carried no such part. + * + *

{@link IHTTPSession#parseBody} fills the supplied map with one entry per uploaded + * file, keyed by the form field name. A malformed or non-form request — or a bare LAN + * probe of the always-on logbook port — leaves the map without {@code file1}. The + * handler used to dereference the missing value directly + * ({@code files.get("file1").hashCode()}), so a request without the part threw a + * {@link NullPointerException}. That throw escaped {@link #serve} before its + * try/catch (the {@code IMPORTLOGDATA} branch runs in the leading dispatch chain), so + * NanoHTTPD's worker aborted the request with no useful response. Callers must treat + * {@code null} as "no upload" and reject the request the way a non-POST method already + * does. Mirrors the {@link #uriSegment} / {@link #parseQueryInt} guards that already + * harden the sibling handlers in this file. + */ + static String uploadedFilePath(Map files) { + return files == null ? null : files.get("file1"); + } + @Override public Response serve(IHTTPSession session) { String[] uriList = session.getUri().split("/"); @@ -283,7 +303,12 @@ private String doImportLogFile(IHTTPSession session) { session.parseBody(files); Log.e(TAG, "doImportLogFile: information:" + files.toString()); - String param = files.get("file1");//this is the key for the POST or PUT file + String param = uploadedFilePath(files);//temp-file path of the file1 upload part (null if absent) + if (param == null) { + // No file1 part: reject like an illegal command instead of NPE-ing on + // the missing upload (see uploadedFilePath). + return GeneralVariables.getStringFromResource(R.string.html_illegal_command); + } ImportTaskList.ImportTask task = importTaskList.addTask(param.hashCode());//create a new task diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/html/LogHttpServerUploadPartTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/html/LogHttpServerUploadPartTest.java new file mode 100644 index 000000000..5be28bebc --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/html/LogHttpServerUploadPartTest.java @@ -0,0 +1,66 @@ +package com.k1af.ft8af.html; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Pure-logic coverage for {@link LogHttpServer#uploadedFilePath(Map)}, the guard that keeps + * the web-logbook {@code /IMPORTLOGDATA} handler from dereferencing a missing upload part. + * + *

Regression: {@code doImportLogFile} read the multipart upload as + * {@code files.get("file1").hashCode()}. A POST/PUT to {@code /IMPORTLOGDATA} that carried + * no {@code file1} file field (a malformed or non-form request, or a bare LAN probe of the + * always-on logbook port) left {@code files} without the key, so the bare + * {@code .hashCode()} threw {@link NullPointerException}. That throw escaped {@code serve} + * before its try/catch — the {@code IMPORTLOGDATA} branch runs in the leading dispatch + * chain — so NanoHTTPD's worker aborted the request with no useful response. The sibling + * handlers in the same file were already hardened + * ({@link LogHttpServer#uriSegment(String[], int)} in PR #584, + * {@link LogHttpServer#parseQueryInt(String, int)} in PR #585); this pins the matching + * guard for the upload part. + * + *

No Android types are touched, so no Robolectric runner is needed (mirrors + * {@link LogHttpServerUriSegmentTest} / {@link LogHttpServerQueryParamTest}). + */ +public class LogHttpServerUploadPartTest { + + @Test + public void presentFilePart_isReturned() { + Map files = new HashMap<>(); + files.put("file1", "/data/tmp/NanoHTTPD-123456.tmp"); + assertThat(LogHttpServer.uploadedFilePath(files)) + .isEqualTo("/data/tmp/NanoHTTPD-123456.tmp"); + } + + @Test + public void missingFilePart_isNull() { + // The regression: no file1 entry (e.g. a POST/PUT with no file field, or a differently + // named part). The old files.get("file1").hashCode() NPE'd here. + assertThat(LogHttpServer.uploadedFilePath(new HashMap<>())).isNull(); + + Map wrongName = new HashMap<>(); + wrongName.put("someOtherField", "/data/tmp/x.tmp"); + assertThat(LogHttpServer.uploadedFilePath(wrongName)).isNull(); + } + + @Test + public void nullMap_isNull() { + // parseBody is only invoked for POST/PUT, but treat a null map defensively so the + // guard never becomes the crash it is meant to prevent. + assertThat(LogHttpServer.uploadedFilePath(null)).isNull(); + } + + @Test + public void emptyStringPath_isReturnedNotSwallowed() { + // An empty path is still "present": the downstream LogFileImport opens it and fails + // with a caught FileNotFoundException, so uploadedFilePath must not conflate "" + // with "absent" (which would silently change the null-only guard's scope). + Map files = new HashMap<>(); + files.put("file1", ""); + assertThat(LogHttpServer.uploadedFilePath(files)).isEqualTo(""); + } +} From 9be3aede1da13079624fa49d11a41af314f01ec7 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 14:07:29 +0000 Subject: [PATCH 015/113] Fix GuoHe Q900 frame sync: require four *consecutive* 0xA5 bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GuoHeQ900Rig.checkHead() counted 0xA5 bytes anywhere in the read buffer and returned as soon as the 4th was seen. But every GuoHe frame begins with FOUR CONSECUTIVE 0xA5 sync bytes (GuoHeRigConstant), and a status frame's payload carries two big-endian VFO frequencies whose bytes are frequently 0xA5. When a serial read splices a prior frame's 0xA5-bearing tail onto the next frame's sync run, the counter reached 4 partway through and returned an index *into* the sync run. onReceiveData then read a 0xA5 as the length byte: (byte)0xA5 + 1 == -90 -> new byte[-90] -> NegativeArraySizeException The exception is swallowed by onReceiveData's try/catch (so it's not an app crash) but it aborts framing before clearBuffer(), leaving stale partial-frame state and silently dropping the frequency update — an intermittent rig frequency-tracking failure on the GuoHe Q900. Fix: reset the run counter on any non-0xA5 byte and return the first byte after a run of >=4 consecutive 0xA5 (the true length byte). This also correctly skips a stray leading 0xA5 that would otherwise make five in a row, and reports "no header yet" (-1) instead of overrunning when a sync run lands at the very end of a read. checkHead is now package-private static (it uses no instance state) so the framing logic is covered directly by GuoHeCheckHeadTest without standing up the rig's Timer/connector. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/rigs/GuoHeQ900Rig.java | 24 ++++- .../k1af/ft8af/rigs/GuoHeCheckHeadTest.java | 94 +++++++++++++++++++ 2 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/rigs/GuoHeCheckHeadTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/GuoHeQ900Rig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/GuoHeQ900Rig.java index 5f0f1f3a0..af4d33159 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/GuoHeQ900Rig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/GuoHeQ900Rig.java @@ -82,14 +82,30 @@ public void setFreqToRig() { } - private int checkHead(byte[] data) { + /** + * Locate the frame length byte, i.e. the first byte after the mandatory + * FOUR CONSECUTIVE 0xA5 sync bytes that begin every GuoHe frame (see + * {@link GuoHeRigConstant}). Returns the index of that length byte, or -1 if + * no sync run is present. + * + *

The 0xA5 bytes must be consecutive: the payload of a status frame + * carries two big-endian VFO frequencies that are frequently 0xA5, and when + * a read splices a prior frame's tail onto the next frame's sync, counting + * 0xA5 bytes anywhere would return an index into the sync run. The + * caller would then read a 0xA5 as the length byte ((byte)0xA5 + 1 = -90), + * allocating {@code new byte[-90]} and throwing NegativeArraySizeException, + * which aborts framing and silently drops the frequency update. + */ + static int checkHead(byte[] data) { int count = 0; for (int i = 0; i < data.length; i++) { if (data[i] == (byte) 0xa5) { count++; - if (count == 4) { - return i+1; - } + } else if (count >= 4) { + // First non-sync byte after a run of >=4 0xA5 is the length byte. + return i; + } else { + count = 0; } } return -1; diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/GuoHeCheckHeadTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/GuoHeCheckHeadTest.java new file mode 100644 index 000000000..a9b33ca27 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/GuoHeCheckHeadTest.java @@ -0,0 +1,94 @@ +package com.k1af.ft8af.rigs; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Coverage for {@link GuoHeQ900Rig#checkHead(byte[])}, which locates the frame + * length byte that follows the mandatory four-consecutive-0xA5 sync header (see + * {@link GuoHeRigConstant}). + * + *

The 0xA5 bytes must be counted consecutively. A status frame's + * payload carries two big-endian VFO frequencies that are frequently 0xA5, so + * counting 0xA5 anywhere (the old behaviour) could return an index into the sync + * run. {@code GuoHeQ900Rig.onReceiveData} would then treat a 0xA5 as the length + * byte — {@code (byte)0xA5 + 1 == -90} — and allocate {@code new byte[-90]}, + * throwing NegativeArraySizeException. The exception aborted framing before + * {@code clearBuffer()}, leaving stale partial-frame state and silently dropping + * the rig's frequency update. + */ +public class GuoHeCheckHeadTest { + + @Test + public void wellFormedFrame_returnsLengthByteIndex() { + // A5 A5 A5 A5 0b ... : length byte is at index 4. + byte[] data = { + (byte) 0xA5, (byte) 0xA5, (byte) 0xA5, (byte) 0xA5, + (byte) 0x0f, (byte) 0x0b, (byte) 0x01, (byte) 0x02}; + assertThat(GuoHeQ900Rig.checkHead(data)).isEqualTo(4); + // The byte at the returned index is the length, never a 0xA5 sync byte. + assertThat(data[GuoHeQ900Rig.checkHead(data)] & 0xFF).isEqualTo(0x0f); + } + + @Test + public void nonConsecutiveA5_isNotMistakenForSync() { + // Three scattered 0xA5 payload bytes then a *real* 4-byte sync run. + // The old counter reached 4 mid-way and returned an index into a run of + // 0xA5 length bytes; the fixed counter resets on every non-0xA5 byte. + byte[] data = { + (byte) 0xA5, (byte) 0x01, (byte) 0xA5, (byte) 0x02, (byte) 0xA5, + (byte) 0xA5, (byte) 0xA5, (byte) 0xA5, (byte) 0xA5, // 4-byte sync + (byte) 0x0f, (byte) 0x0b}; + int idx = GuoHeQ900Rig.checkHead(data); + assertThat(idx).isEqualTo(9); + assertThat(data[idx] & 0xFF).isEqualTo(0x0f); + } + + @Test + public void statusFrameWithA5FreqBytes_thenNextSync_findsLengthNotA5() { + // Realistic stream splice: the tail of a status frame whose VFO + // frequency bytes are 0xA5 (14.074 MHz style values land on 0xA5), the + // 2-byte CRC, then the next frame's 4-byte sync + length byte. The old + // code returned an index landing on a 0xA5 -> len = -90 -> new byte[-90]. + byte[] data = { + // trailing payload of a previous frame with embedded 0xA5s + (byte) 0x00, (byte) 0xA5, (byte) 0xC0, (byte) 0xA5, (byte) 0x37, + // next frame sync + length + (byte) 0xA5, (byte) 0xA5, (byte) 0xA5, (byte) 0xA5, + (byte) 0x03, (byte) 0x0b}; + int idx = GuoHeQ900Rig.checkHead(data); + assertThat(idx).isEqualTo(9); + // Must be a valid (non-negative) length byte, never a 0xA5 sync byte. + int len = data[idx] + 1; + assertThat(len).isGreaterThan(0); + assertThat(data[idx] & 0xFF).isNotEqualTo(0xA5); + } + + @Test + public void moreThanFourConsecutiveA5_skipsEntireSyncRun() { + // A stray leading 0xA5 (e.g. previous CRC low byte) makes five in a row. + // The length byte is the first non-0xA5 after the whole run, not the 5th 0xA5. + byte[] data = { + (byte) 0xA5, (byte) 0xA5, (byte) 0xA5, (byte) 0xA5, (byte) 0xA5, + (byte) 0x05, (byte) 0x0b}; + int idx = GuoHeQ900Rig.checkHead(data); + assertThat(idx).isEqualTo(5); + assertThat(data[idx] & 0xFF).isEqualTo(0x05); + } + + @Test + public void noSyncHeader_returnsMinusOne() { + byte[] data = {(byte) 0x01, (byte) 0x02, (byte) 0xA5, (byte) 0xA5, (byte) 0x03}; + assertThat(GuoHeQ900Rig.checkHead(data)).isEqualTo(-1); + } + + @Test + public void syncRunAtBufferEnd_returnsMinusOne_ratherThanOverrunning() { + // The 4-byte sync arrives but the length byte is in the next read. The + // fixed code reports "no complete header yet" (-1) instead of returning + // an out-of-range index that would throw on data[startIndex]. + byte[] data = {(byte) 0xA5, (byte) 0xA5, (byte) 0xA5, (byte) 0xA5}; + assertThat(GuoHeQ900Rig.checkHead(data)).isEqualTo(-1); + } +} From 6a9ed23baddae8a6ea5395ef6ec897c3969ba8dc Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 14:19:42 +0000 Subject: [PATCH 016/113] Fix SWR protection on Lab599 TX-500 (#599) The TX-500 inherited its meter read+parse from KenwoodTS2000Rig, which is incompatible with the TX-500's CAT interface, so SWR always stayed 0 and MeterProtectionController never tripped: - readMeters() only ever sent RM;. The TX-500 needs RM1; to switch the meter to SWR first, then RM; returns the reading. - The RM reply RM1vvvv puts the SWR selector at index 0 (not index 2 like the TS-590) and the value at substring(1,5), so is590MeterSWR() never matched. - The raw 0000-0030 field needed a TX-500-specific curve to normalize into the 0-255 scale MeterProtectionController's halt threshold uses. Give DiscoveryTX500Rig its own meter handling: send RM1; before RM;, parse the RM1vvvv layout, and map the 0-30 field to an SWR ratio (linear per Lab599's published chart: swr = 1 + cat/3) normalized on the same scale the halt threshold is stored on. ALC is reported as -1 (not present on this rig). KenwoodTS2000Rig gains protected sendMeterReadCommand()/handleMeterReply() hooks so the override is a clean seam rather than a copy of the RX path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../k1af/ft8af/rigs/DiscoveryTX500Rig.java | 90 +++++++++++++++++++ .../ft8af/rigs/KenwoodTK90RigConstant.java | 5 ++ .../com/k1af/ft8af/rigs/KenwoodTS2000Rig.java | 41 ++++++--- .../com/k1af/ft8af/rigs/Yaesu3Command.java | 26 ++++++ .../ft8af/rigs/DiscoveryTX500RigTest.java | 67 ++++++++++++++ .../k1af/ft8af/rigs/Yaesu3CommandTest.java | 32 +++++++ 6 files changed, 250 insertions(+), 11 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/DiscoveryTX500Rig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/DiscoveryTX500Rig.java index ec0a6e88e..ed72b7642 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/DiscoveryTX500Rig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/DiscoveryTX500Rig.java @@ -1,11 +1,37 @@ package com.k1af.ft8af.rigs; +import com.k1af.ft8af.GeneralVariables; +import com.k1af.ft8af.R; +import com.k1af.ft8af.ft8transmit.MeterProtectionController; +import com.k1af.ft8af.ui.ToastMessage; + /** * Lab599 Discovery TX-500. CAT-identical to KENWOOD TS-2000, except it is placed in * DATA mode (MD6;) instead of USB (MD2;) so the rig takes audio from the USB line * input rather than the microphone. Issue #108. + * + *

Its SWR meter, however, does not follow the TS-2000/TS-590 RM layout, so meter + * reading is handled here rather than inherited (issue #599): + *

    + *
  • The rig must be switched to the SWR meter with {@code RM1;} before {@code RM;} + * returns a reading — the inherited path only ever sends {@code RM;}.
  • + *
  • The reply is {@code RM1vvvv}: the selector digit is at index 0 ({@code '1'} = + * SWR), not index 2, and the value is the four digits {@code substring(1,5)} + * (a raw 0000-0030 field), not the TS-590's layout.
  • + *
  • The 0-30 field maps to an SWR ratio via Lab599's published chart + * ({@link #tx500CatToSwrRatio(int)}); only that ratio, normalized the same way + * the SWR-halt threshold is stored, makes {@link MeterProtectionController} fire.
  • + *
*/ public class DiscoveryTX500Rig extends KenwoodTS2000Rig { + + /** + * Actual SWR the toast warning fires at (mirrors the TS-590's ~3.0:1 alert point). + */ + private static final float TX500_SWR_ALERT_RATIO = 3.0f; + + private boolean swrAlert = false; + @Override public void setUsbModeToRig() { if (getConnector() != null) { @@ -13,6 +39,70 @@ public void setUsbModeToRig() { } } + /** + * The TX-500 needs the meter switched to SWR ({@code RM1;}) before {@code RM;} + * returns an SWR reading; the inherited path only sends {@code RM;}. + */ + @Override + protected void sendMeterReadCommand() { + getConnector().sendData(KenwoodTK90RigConstant.setTX500SelectSwrMeter()); + getConnector().sendData(KenwoodTK90RigConstant.setRead590Meters()); + } + + /** + * Parse the TX-500's {@code RM1vvvv} SWR reply and feed the protection controller. + * The TX-500 reports no ALC meter here, so ALC is passed as {@code -1} ("not + * reported"). + */ + @Override + protected void handleMeterReply(Yaesu3Command yaesu3Command) { + if (!Yaesu3Command.isTX500MeterSWR(yaesu3Command)) { + return; // not the SWR meter — ignore rather than mis-parse as TS-590 + } + int normalizedSwr = tx500CatToNormalizedSwr(Yaesu3Command.getTX500MeterValue(yaesu3Command)); + showSwrAlert(normalizedSwr); + notifyMeterData(-1, normalizedSwr); + } + + private void showSwrAlert(int normalizedSwr) { + boolean high = normalizedSwr >= MeterProtectionController.swrRatioToNormalized(TX500_SWR_ALERT_RATIO) + && GeneralVariables.swr_switch_on; + if (high) { + if (!swrAlert) { + swrAlert = true; + ToastMessage.show(GeneralVariables.getStringFromResource(R.string.swr_high_alert)); + } + } else { + swrAlert = false; + } + } + + /** + * Map the TX-500's raw 0000-0030 SWR meter field to an actual SWR ratio. + * Per Lab599's published value→SWR chart the relationship is linear: + * {@code cat = 3 * (swr - 1)} (cat 6 → 3.0:1, cat 27 → 10:1), i.e. + * {@code swr = 1 + cat / 3}. + * + * @param catValue raw 0-30 meter value + * @return the corresponding SWR ratio (never below 1.0) + */ + static float tx500CatToSwrRatio(int catValue) { + if (catValue <= 0) return 1.0f; + return 1.0f + catValue / 3.0f; + } + + /** + * Map the raw 0000-0030 field to a 0-255 SWR value on the same scale the SWR-halt + * threshold is stored on ({@link MeterProtectionController#swrRatioToNormalized}), + * so the configured halt ratio is meaningful for this rig. + * + * @param catValue raw 0-30 meter value + * @return normalized 0-255 SWR value + */ + static int tx500CatToNormalizedSwr(int catValue) { + return MeterProtectionController.swrRatioToNormalized(tx500CatToSwrRatio(catValue)); + } + @Override public String getName() { return "Discovery TX-500"; diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTK90RigConstant.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTK90RigConstant.java index f178615e3..8cb7a6fbb 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTK90RigConstant.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTK90RigConstant.java @@ -39,6 +39,7 @@ public class KenwoodTK90RigConstant { private static final String TS2000_SET_DATA = "MD6;";//KENWOOD/TX-500 DATA mode (USB line input) private static final String TS590_READ_FREQ = "FA;";//KENWOOD read frequency private static final String TS590_READ_METERS = "RM;";//KENWOOD read METER + private static final String TX500_SELECT_SWR_METER = "RM1;";//Lab599 TX-500: switch meter to SWR before reading (issue #599) private static final String TS570_PTT_OFF = "RX;";//KENWOOD TS570,PTT private static final String TS570_PTT_ON = "TX;";//KENWOOD TS570,PTT @@ -167,6 +168,10 @@ public static byte[] setRead590Meters() { return TS590_READ_METERS.getBytes(); } + public static byte[] setTX500SelectSwrMeter() { + return TX500_SELECT_SWR_METER.getBytes(); + } + public static byte[] setTS590ReadOperationFreq() { return TS590_READ_FREQ.getBytes(); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTS2000Rig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTS2000Rig.java index 12347f253..2ab74e55d 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTS2000Rig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTS2000Rig.java @@ -59,17 +59,26 @@ public void run() { /** * Read Meter RM; */ - private void readMeters() { + protected void readMeters() { if (getConnector() != null) { clearBufferData();//clear buffer - getConnector().sendData(KenwoodTK90RigConstant.setRead590Meters()); + sendMeterReadCommand(); } } + /** + * Send the CAT command(s) that request a meter reading. The TS-2000/TS-590 + * just polls {@code RM;}; subclasses whose rig needs a meter-select first + * (e.g. the TX-500's {@code RM1;} SWR select) override this. Issue #599. + */ + protected void sendMeterReadCommand() { + getConnector().sendData(KenwoodTK90RigConstant.setRead590Meters()); + } + /** * Clear buffer data */ - private void clearBufferData() { + protected void clearBufferData() { buffer.setLength(0); } @@ -151,20 +160,30 @@ public void onReceiveData(byte[] data) { setFreq(Yaesu3Command.getFrequency(yaesu3Command)); } } else if (cmd.equalsIgnoreCase("RM")) {//meter - if (Yaesu3Command.is590MeterSWR(yaesu3Command)) { - swr = Yaesu3Command.get590ALCOrSWR(yaesu3Command); - } - if (Yaesu3Command.is590MeterALC(yaesu3Command)) { - alc = Yaesu3Command.get590ALCOrSWR(yaesu3Command); - } - showAlert(); - notifyMeterData(Math.min(alc * 8, 255), Math.min(swr * 8, 255)); + handleMeterReply(yaesu3Command); } } } + /** + * Parse an RM meter reply and forward the result to the protection + * controller. The TS-2000/TS-590 layout puts the selector at index 2 and the + * value at {@code substring(1,5)}; rigs with a different RM layout (e.g. the + * TX-500) override this. Issue #599. + */ + protected void handleMeterReply(Yaesu3Command yaesu3Command) { + if (Yaesu3Command.is590MeterSWR(yaesu3Command)) { + swr = Yaesu3Command.get590ALCOrSWR(yaesu3Command); + } + if (Yaesu3Command.is590MeterALC(yaesu3Command)) { + alc = Yaesu3Command.get590ALCOrSWR(yaesu3Command); + } + showAlert(); + notifyMeterData(Math.min(alc * 8, 255), Math.min(swr * 8, 255)); + } + private void showAlert() { if ((swr >= KenwoodTK90RigConstant.ts_590_swr_alert_max) && GeneralVariables.swr_switch_on) { diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu3Command.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu3Command.java index f3c6e0c00..03cc31b22 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu3Command.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu3Command.java @@ -131,6 +131,32 @@ public static boolean is590MeterSWR(Yaesu3Command command){ return command.data.charAt(2) == '1'; } + /** + * Lab599 Discovery TX-500 SWR meter reply. Unlike the TS-590 layout (meter + * selector at index 2), the TX-500 answers {@code RM;} with {@code RM1vvvv} + * where the leading digit is the meter selector ({@code '1'} = SWR) and the + * four following digits are the meter value (0000-0030). Issue #599. + * + * @param command RM command + * @return true when the reply carries the SWR meter + */ + public static boolean isTX500MeterSWR(Yaesu3Command command) { + if (command.data.length() < 5) return false; + return command.data.charAt(0) == '1'; + } + + /** + * Value of the TX-500 SWR meter reply: the four digits after the selector + * ({@code substring(1,5)}), a raw 0000-0030 field. Callers map it to an SWR + * ratio; see {@link DiscoveryTX500Rig#tx500CatToSwrRatio(int)}. + * + * @param command RM command in {@code RM1vvvv} form + * @return the raw 0-30 meter value, or 0 when the reply is too short + */ + public static int getTX500MeterValue(Yaesu3Command command) { + if (command.data.length() < 5) return 0; + return Integer.parseInt(command.data.substring(1, 5)); + } } \ No newline at end of file diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java new file mode 100644 index 000000000..b3e10bca2 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java @@ -0,0 +1,67 @@ +package com.k1af.ft8af.rigs; + +import static com.google.common.truth.Truth.assertThat; + +import com.k1af.ft8af.ft8transmit.MeterProtectionController; + +import org.junit.Test; + +/** + * Pure-logic coverage for the Lab599 Discovery TX-500 SWR meter mapping (#599). + * + * The rig reports SWR as a raw 0000-0030 field; per Lab599's published chart the + * relationship to actual SWR is linear ({@code cat = 3 * (swr - 1)}), and the value + * handed to {@link MeterProtectionController} must sit on the same 0-255 scale the + * SWR-halt threshold is stored on, or protection can never trip. + */ +public class DiscoveryTX500RigTest { + + @Test + public void catToSwrRatio_matchesPublishedChart() { + // cat = 3 * (swr - 1): a few anchor points straight from the chart. + assertThat(DiscoveryTX500Rig.tx500CatToSwrRatio(0)).isEqualTo(1.0f); + assertThat(DiscoveryTX500Rig.tx500CatToSwrRatio(6)).isEqualTo(3.0f); // 3.0:1 + assertThat(DiscoveryTX500Rig.tx500CatToSwrRatio(9)).isEqualTo(4.0f); // 4.0:1 + assertThat(DiscoveryTX500Rig.tx500CatToSwrRatio(18)).isEqualTo(7.0f); // 7.0:1 + assertThat(DiscoveryTX500Rig.tx500CatToSwrRatio(27)).isEqualTo(10.0f); // 10:1 + } + + @Test + public void catToSwrRatio_neverBelowUnity() { + assertThat(DiscoveryTX500Rig.tx500CatToSwrRatio(-1)).isEqualTo(1.0f); + assertThat(DiscoveryTX500Rig.tx500CatToSwrRatio(0)).isEqualTo(1.0f); + } + + @Test + public void catToSwrRatio_isMonotonic() { + float prev = -1f; + for (int cat = 0; cat <= 30; cat++) { + float ratio = DiscoveryTX500Rig.tx500CatToSwrRatio(cat); + assertThat(ratio).isAtLeast(prev); + prev = ratio; + } + } + + @Test + public void catToNormalizedSwr_alignsWithHaltThresholdScale() { + // cat 6 == 3.0:1 == the default halt threshold (120) once normalized. + assertThat(DiscoveryTX500Rig.tx500CatToNormalizedSwr(6)) + .isEqualTo(MeterProtectionController.swrRatioToNormalized(3.0f)); + assertThat(DiscoveryTX500Rig.tx500CatToNormalizedSwr(6)).isEqualTo(120); + + // A clean match (cat 0) and a high reading (cat 30 -> >10:1 -> clamps to 255). + assertThat(DiscoveryTX500Rig.tx500CatToNormalizedSwr(0)).isEqualTo(0); + assertThat(DiscoveryTX500Rig.tx500CatToNormalizedSwr(30)).isEqualTo(255); + } + + @Test + public void catToNormalizedSwr_trippsHaltAboveDefaultThreshold() { + // Default swr-halt threshold is 120 (~3.0:1); a reading of 23 (~8.7:1) must halt. + int normalized = DiscoveryTX500Rig.tx500CatToNormalizedSwr(23); + assertThat(MeterProtectionController.shouldHaltForSwr(normalized, true, 120)).isTrue(); + + // A low reading (cat 3 ~= 2.0:1) must NOT halt at the default threshold. + int low = DiscoveryTX500Rig.tx500CatToNormalizedSwr(3); + assertThat(MeterProtectionController.shouldHaltForSwr(low, true, 120)).isFalse(); + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu3CommandTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu3CommandTest.java index 71ecc76c9..4fb5c2698 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu3CommandTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu3CommandTest.java @@ -120,4 +120,36 @@ public void ts590_shortDataSelectorsFalse() { assertThat(Yaesu3Command.is590MeterALC(c)).isFalse(); assertThat(Yaesu3Command.is590MeterSWR(c)).isFalse(); } + + // ---------- Lab599 Discovery TX-500 (#599) ---------- + + @Test + public void tx500_swrSelectorAtIndex0AndValue() { + // RM10023 -> data "10023": selector '1' at index 0 = SWR; value substring(1,5) = 23. + Yaesu3Command swr = Yaesu3Command.getCommand("RM10023"); + assertThat(Yaesu3Command.isTX500MeterSWR(swr)).isTrue(); + assertThat(Yaesu3Command.getTX500MeterValue(swr)).isEqualTo(23); + } + + @Test + public void tx500_zeroValueParsed() { + Yaesu3Command swr = Yaesu3Command.getCommand("RM10000"); + assertThat(Yaesu3Command.isTX500MeterSWR(swr)).isTrue(); + assertThat(Yaesu3Command.getTX500MeterValue(swr)).isEqualTo(0); + } + + @Test + public void tx500_nonSwrSelectorRejected() { + // A reply that does not lead with '1' is not the SWR meter (unlike the TS-590, + // whose selector sits at index 2 — here index 2 being '1' must NOT match). + Yaesu3Command notSwr = Yaesu3Command.getCommand("RM00123"); + assertThat(Yaesu3Command.isTX500MeterSWR(notSwr)).isFalse(); + } + + @Test + public void tx500_shortDataDefaults() { + Yaesu3Command c = Yaesu3Command.getCommand("RM10"); // data "10" len 2 + assertThat(Yaesu3Command.isTX500MeterSWR(c)).isFalse(); + assertThat(Yaesu3Command.getTX500MeterValue(c)).isEqualTo(0); + } } From 0d8ad2640be3c6d20595e95904eed795d88f1143 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 14:25:31 +0000 Subject: [PATCH 017/113] Export QSOs: add Material date picker to date-range filter The From/To date-range fields in the Export QSOs sheet were plain numeric EditTexts requiring a hand-typed YYYYMMDD value with no calendar UI and no validation. This adds a MaterialDatePicker behind a trailing calendar icon on each field while keeping the field fully editable by keyboard. - Tapping the calendar icon opens a Material date picker; a valid YYYYMMDD already in the field is preselected, otherwise today. - Selecting a date fills the field as YYYYMMDD; typed entry still works and a field can be cleared back to empty (empty = no bound, unchanged). - On Share / Save, a non-empty field that is not a strict YYYYMMDD date is rejected with a ToastMessage instead of running a broken query. - The value handed to ShareLogs remains a YYYYMMDD string (or null), so the query-layer date contract is unchanged. Date parse/validate/format logic is extracted to package-private static helpers on ExportLogSheet and covered by ExportLogSheetDateTest. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/ui/ExportLogSheet.java | 124 +++++++++++++++++- .../main/res/drawable/ic_calendar_today.xml | 10 ++ .../src/main/res/layout/dialog_export_log.xml | 16 ++- .../k1af/ft8af/ui/ExportLogSheetDateTest.java | 118 +++++++++++++++++ 4 files changed, 260 insertions(+), 8 deletions(-) create mode 100644 ft8af/app/src/main/res/drawable/ic_calendar_today.xml create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/ui/ExportLogSheetDateTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java index 25527403d..16b8d9f1d 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java @@ -1,8 +1,12 @@ package com.k1af.ft8af.ui; +import android.annotation.SuppressLint; import android.app.Dialog; import android.content.Context; +import android.content.ContextWrapper; +import android.graphics.drawable.Drawable; import android.os.Bundle; +import android.view.MotionEvent; import android.view.View; import android.view.WindowManager; import android.widget.Button; @@ -10,6 +14,10 @@ import android.widget.ProgressBar; import android.widget.TextView; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; + +import com.google.android.material.datepicker.MaterialDatePicker; import com.k1af.ft8af.GeneralVariables; import com.k1af.ft8af.MainViewModel; import com.k1af.ft8af.R; @@ -17,9 +25,11 @@ import com.k1af.ft8af.log.ShareLogs; import java.io.File; +import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; +import java.util.TimeZone; /** * Sheet for exporting QSO records to ADIF, then either sharing via the system @@ -45,6 +55,8 @@ protected void onCreate(Bundle savedInstanceState) { final TextView summary = findViewById(R.id.exportSummaryTextView); final EditText dateStart = findViewById(R.id.exportDateStart); final EditText dateEnd = findViewById(R.id.exportDateEnd); + setupDatePicker(dateStart, "exportDatePickerStart"); + setupDatePicker(dateEnd, "exportDatePickerEnd"); final TextView progressText = findViewById(R.id.exportProgressTextView); final ProgressBar progressBar = findViewById(R.id.exportProgressBar); Button cancel = findViewById(R.id.exportCancelButton); @@ -75,14 +87,15 @@ public void onClick(View view) { @Override public void onClick(View view) { if (working) return; + if (!validateDate(dateStart, "From") || !validateDate(dateEnd, "To")) return; + final String startDate = nullIfEmpty(dateStart.getText().toString()); + final String endDate = nullIfEmpty(dateEnd.getText().toString()); working = true; share.setEnabled(false); save.setEnabled(false); progressBar.setVisibility(View.VISIBLE); final File adi = generateTempAdi(); if (adi == null) return; - final String startDate = nullIfEmpty(dateStart.getText().toString()); - final String endDate = nullIfEmpty(dateEnd.getText().toString()); new Thread(new Runnable() { @Override public void run() { @@ -105,14 +118,15 @@ public void run() { @Override public void onClick(View view) { if (working) return; + if (!validateDate(dateStart, "From") || !validateDate(dateEnd, "To")) return; + final String startDate = nullIfEmpty(dateStart.getText().toString()); + final String endDate = nullIfEmpty(dateEnd.getText().toString()); working = true; share.setEnabled(false); save.setEnabled(false); progressBar.setVisibility(View.VISIBLE); final File adi = generateTempAdi(); if (adi == null) return; - final String startDate = nullIfEmpty(dateStart.getText().toString()); - final String endDate = nullIfEmpty(dateEnd.getText().toString()); final String displayName = "ft8af-log-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(new Date()) + ".adi"; @@ -206,6 +220,108 @@ private static String nullIfEmpty(String s) { return s.isEmpty() ? null : s; } + /** + * Wire up an editable date field so tapping its trailing calendar icon opens a + * {@link MaterialDatePicker}. The field stays fully editable by keyboard; the + * picker just fills in a {@code YYYYMMDD} string, keeping the two input paths + * in sync and producing the exact format {@code ShareLogs} expects. + */ + @SuppressLint("ClickableViewAccessibility") + private void setupDatePicker(final EditText field, final String tag) { + field.setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + if (event.getAction() != MotionEvent.ACTION_UP) return false; + Drawable end = field.getCompoundDrawablesRelative()[2]; + if (end == null) return false; + int hit = field.getWidth() - field.getPaddingEnd() - end.getBounds().width(); + if (event.getX() >= hit) { + field.performClick(); + openDatePicker(field, tag); + return true; + } + return false; + } + }); + } + + private void openDatePicker(final EditText field, String tag) { + FragmentManager fm = fragmentManager(); + if (fm == null || fm.isStateSaved() || fm.findFragmentByTag(tag) != null) return; + Long preselect = parseYyyyMmddUtc(field.getText().toString()); + MaterialDatePicker.Builder builder = MaterialDatePicker.Builder.datePicker(); + builder.setSelection(preselect != null ? preselect + : MaterialDatePicker.todayInUtcMilliseconds()); + MaterialDatePicker picker = builder.build(); + picker.addOnPositiveButtonClickListener(new com.google.android.material.datepicker.MaterialPickerOnPositiveButtonClickListener() { + @Override + public void onPositiveButtonClick(Long selection) { + if (selection != null) field.setText(formatYyyyMmddUtc(selection)); + } + }); + picker.show(fm, tag); + } + + /** Unwrap {@link #getContext()} to the hosting {@link FragmentActivity}, if any. */ + private FragmentManager fragmentManager() { + Context c = getContext(); + while (c instanceof ContextWrapper) { + if (c instanceof FragmentActivity) { + return ((FragmentActivity) c).getSupportFragmentManager(); + } + c = ((ContextWrapper) c).getBaseContext(); + } + return null; + } + + /** + * Validate a date field before export. An empty field is valid ("no bound"). + * A non-empty field must be a strict {@code YYYYMMDD} date; otherwise a toast + * is shown and {@code false} returned so the caller aborts the export. + */ + private boolean validateDate(EditText field, String label) { + if (isValidOptionalDate(field.getText().toString())) return true; + ToastMessage.show("Invalid '" + label + "' date — use YYYYMMDD (e.g. 20260722)"); + return false; + } + + /** True when {@code s} is empty/null (no bound) or a strict {@code YYYYMMDD} date. */ + static boolean isValidOptionalDate(String s) { + if (s == null) return true; + s = s.trim(); + return s.isEmpty() || parseYyyyMmddUtc(s) != null; + } + + /** + * Parse a strict 8-digit {@code YYYYMMDD} string to UTC-midnight millis (the + * form {@link MaterialDatePicker} uses), or {@code null} if it is not a real + * calendar date. Used both to validate typed input and to preselect the picker. + */ + static Long parseYyyyMmddUtc(String s) { + if (s == null) return null; + s = s.trim(); + if (!s.matches("\\d{8}")) return null; + SimpleDateFormat fmt = new SimpleDateFormat("yyyyMMdd", Locale.US); + fmt.setTimeZone(TimeZone.getTimeZone("UTC")); + fmt.setLenient(false); + try { + Date d = fmt.parse(s); + // Round-trip so out-of-band values SimpleDateFormat still accepts + // (e.g. year 0000) are rejected rather than silently normalized. + if (d == null || !fmt.format(d).equals(s)) return null; + return d.getTime(); + } catch (ParseException e) { + return null; + } + } + + /** Format {@link MaterialDatePicker}'s UTC-millis selection as {@code YYYYMMDD}. */ + static String formatYyyyMmddUtc(long utcMillis) { + SimpleDateFormat fmt = new SimpleDateFormat("yyyyMMdd", Locale.US); + fmt.setTimeZone(TimeZone.getTimeZone("UTC")); + return fmt.format(new Date(utcMillis)); + } + private static String filterLabel(int queryFilter) { switch (queryFilter) { case 1: return "confirmed only"; diff --git a/ft8af/app/src/main/res/drawable/ic_calendar_today.xml b/ft8af/app/src/main/res/drawable/ic_calendar_today.xml new file mode 100644 index 000000000..85f42289c --- /dev/null +++ b/ft8af/app/src/main/res/drawable/ic_calendar_today.xml @@ -0,0 +1,10 @@ + + + diff --git a/ft8af/app/src/main/res/layout/dialog_export_log.xml b/ft8af/app/src/main/res/layout/dialog_export_log.xml index 69df5d2f5..e25371f6c 100644 --- a/ft8af/app/src/main/res/layout/dialog_export_log.xml +++ b/ft8af/app/src/main/res/layout/dialog_export_log.xml @@ -70,15 +70,19 @@ @@ -91,15 +95,19 @@ diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ui/ExportLogSheetDateTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ui/ExportLogSheetDateTest.java new file mode 100644 index 000000000..e79a174e4 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ui/ExportLogSheetDateTest.java @@ -0,0 +1,118 @@ +package com.k1af.ft8af.ui; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +import java.util.TimeZone; + +/** + * Unit tests for the date parsing/validation/formatting helpers in + * {@link ExportLogSheet} that back the Export QSOs date-range picker. These + * guarantee the picker path and the typed path both yield the strict + * {@code YYYYMMDD} string the {@code ShareLogs} query layer expects. + */ +public class ExportLogSheetDateTest { + + // MaterialDatePicker.todayInUtcMilliseconds() is UTC-based, so all conversions + // must be UTC regardless of the test machine's default zone. + private static final long UTC_20260722 = utc(2026, 7, 22); + + private static long utc(int year, int month, int day) { + java.util.Calendar c = java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")); + c.clear(); + c.set(year, month - 1, day, 0, 0, 0); + return c.getTimeInMillis(); + } + + // ---- isValidOptionalDate: empty means "no bound" and is valid ---- + + @Test + public void nullIsValid() { + assertThat(ExportLogSheet.isValidOptionalDate(null)).isTrue(); + } + + @Test + public void emptyIsValid() { + assertThat(ExportLogSheet.isValidOptionalDate("")).isTrue(); + assertThat(ExportLogSheet.isValidOptionalDate(" ")).isTrue(); + } + + @Test + public void wellFormedDateIsValid() { + assertThat(ExportLogSheet.isValidOptionalDate("20260722")).isTrue(); + } + + @Test + public void surroundingWhitespaceIsTolerated() { + assertThat(ExportLogSheet.isValidOptionalDate(" 20260722 ")).isTrue(); + } + + // ---- isValidOptionalDate: malformed non-empty input is rejected ---- + + @Test + public void wrongLengthIsInvalid() { + assertThat(ExportLogSheet.isValidOptionalDate("2026072")).isFalse(); // 7 digits + assertThat(ExportLogSheet.isValidOptionalDate("202607221")).isFalse(); // 9 digits + } + + @Test + public void nonNumericIsInvalid() { + assertThat(ExportLogSheet.isValidOptionalDate("2026-07-22")).isFalse(); + assertThat(ExportLogSheet.isValidOptionalDate("2026Jul2")).isFalse(); + } + + @Test + public void impossibleMonthIsInvalid() { + assertThat(ExportLogSheet.isValidOptionalDate("20261301")).isFalse(); // month 13 + assertThat(ExportLogSheet.isValidOptionalDate("20260001")).isFalse(); // month 00 + } + + @Test + public void impossibleDayIsInvalid() { + assertThat(ExportLogSheet.isValidOptionalDate("20260230")).isFalse(); // Feb 30 + assertThat(ExportLogSheet.isValidOptionalDate("20260231")).isFalse(); // Feb 31 + assertThat(ExportLogSheet.isValidOptionalDate("20260732")).isFalse(); // day 32 + assertThat(ExportLogSheet.isValidOptionalDate("20260700")).isFalse(); // day 00 + } + + @Test + public void leapDayValidatesCorrectly() { + assertThat(ExportLogSheet.isValidOptionalDate("20240229")).isTrue(); // 2024 is a leap year + assertThat(ExportLogSheet.isValidOptionalDate("20260229")).isFalse(); // 2026 is not + } + + @Test + public void yearZeroIsRejected() { + // SimpleDateFormat would otherwise silently normalize this — the round-trip guards it. + assertThat(ExportLogSheet.isValidOptionalDate("00000101")).isFalse(); + } + + // ---- parseYyyyMmddUtc: preselect value for the picker ---- + + @Test + public void parseReturnsUtcMidnightMillis() { + assertThat(ExportLogSheet.parseYyyyMmddUtc("20260722")).isEqualTo(UTC_20260722); + } + + @Test + public void parseInvalidReturnsNull() { + assertThat(ExportLogSheet.parseYyyyMmddUtc("not-a-date")).isNull(); + assertThat(ExportLogSheet.parseYyyyMmddUtc("")).isNull(); + assertThat(ExportLogSheet.parseYyyyMmddUtc(null)).isNull(); + } + + // ---- formatYyyyMmddUtc: what a picker selection becomes ---- + + @Test + public void formatProducesYyyyMmdd() { + assertThat(ExportLogSheet.formatYyyyMmddUtc(UTC_20260722)).isEqualTo("20260722"); + } + + @Test + public void formatAndParseRoundTrip() { + Long millis = ExportLogSheet.parseYyyyMmddUtc("20250101"); + assertThat(millis).isNotNull(); + assertThat(ExportLogSheet.formatYyyyMmddUtc(millis)).isEqualTo("20250101"); + } +} From 413b6be2a1b3db1503d46297bcbfd363977a91a9 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 14:25:43 +0000 Subject: [PATCH 018/113] Fix typo in TX-500 test method name (trips) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java index b3e10bca2..7cede35b7 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/DiscoveryTX500RigTest.java @@ -55,7 +55,7 @@ public void catToNormalizedSwr_alignsWithHaltThresholdScale() { } @Test - public void catToNormalizedSwr_trippsHaltAboveDefaultThreshold() { + public void catToNormalizedSwr_tripsHaltAboveDefaultThreshold() { // Default swr-halt threshold is 120 (~3.0:1); a reading of 23 (~8.7:1) must halt. int normalized = DiscoveryTX500Rig.tx500CatToNormalizedSwr(23); assertThat(MeterProtectionController.shouldHaltForSwr(normalized, true, 120)).isTrue(); From 0ca2f4f1288c763a6fb55aaf0268b3c70d289fa5 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 14:17:19 +0000 Subject: [PATCH 019/113] Preserve TX Delay offset across timer rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TX Delay was not applied until the value was changed after startup. The saved delay is loaded into GeneralVariables.transmitDelay and applied to the current UtcTimer in ComposeMainActivity.initData(), but the very next call — applyLoadedOperatingMode() -> FT8TransmitSignal.rebuildTimer() — threw the timer away and built a fresh one that starts with time_sec = 0, silently discarding the delay until the operator re-edited it. The same reset hit runtime FT8/FT4/FT2 mode switches. Carry the outgoing timer's offset onto the rebuilt one inside a new package-visible static helper (rebuildTimerPreservingOffset) so the fix covers both startup and mode switches, and the carry-over is unit-testable via getTime_sec() without constructing a full FT8TransmitSignal. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ft8af/ft8transmit/FT8TransmitSignal.java | 29 +++++++++- .../ft8transmit/FT8TransmitSignalTest.java | 56 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java index 0e33b22bf..613a8f779 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java @@ -261,8 +261,7 @@ public void doOnSecTimer(long utc) { * timer is always restarted; callers should ensure we are not mid-transmit first. */ public void rebuildTimer(ModeProfile mode) { - utcTimer.delete(); - utcTimer = new UtcTimer(mode.slotMillis, false, makeTimerCallback()); + utcTimer = rebuildTimerPreservingOffset(utcTimer, mode, makeTimerCallback()); utcTimer.start(); } @@ -325,6 +324,32 @@ static ManualTxGate decideManualTx(long msInCycle, int audioSlackMillis, return new ManualTxGate(transmit, (int) clip); } + /** + * Build the replacement cycle timer for a new operating mode, carrying the manual + * TX-delay offset ({@link UtcTimer#getTime_sec()}) from the outgoing timer onto the + * fresh one. A new {@link UtcTimer} starts with {@code time_sec = 0}, so without this + * carry-over the saved TX Delay loaded at startup (or set before a mode switch) is + * silently discarded — the running signal falls back to a 0 ms offset until the value + * is edited again. Preserving it here fixes both the startup case and FT8/FT4/FT2 + * runtime mode switches. + * + *

Package-visible and static so the offset carry-over is unit-testable via + * {@code getTime_sec()} without constructing a full {@link FT8TransmitSignal}. + * + * @param oldTimer the timer being retired (its offset is read, then it is deleted) + * @param mode the mode whose {@link ModeProfile#slotMillis} sets the new period + * @param callback the cycle-trigger callback for the new timer + * @return the new, not-yet-started timer with the carried-over offset applied + */ + static UtcTimer rebuildTimerPreservingOffset(UtcTimer oldTimer, ModeProfile mode, + OnUtcTimer callback) { + int prevDelay = oldTimer.getTime_sec(); + oldTimer.delete(); + UtcTimer next = new UtcTimer(mode.slotMillis, false, callback); + next.setTime_sec(prevDelay); + return next; + } + /** * Transmit immediately. */ diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignalTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignalTest.java index d47d0b9b2..c56515a1e 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignalTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignalTest.java @@ -2,6 +2,10 @@ import static com.google.common.truth.Truth.assertThat; +import com.k1af.ft8af.ModeProfile; +import com.k1af.ft8af.timer.OnUtcTimer; +import com.k1af.ft8af.timer.UtcTimer; + import org.junit.Test; /** @@ -643,4 +647,56 @@ public void manualTx_clipClampedBelowSlot() { assertThat(g.transmit).isFalse(); assertThat(g.clipMs).isEqualTo(FT8_SLOT - 1); } + + // ---- rebuildTimerPreservingOffset --------------------------------------- + // A mode change (or the applyLoadedOperatingMode() call at startup) rebuilds + // the cycle timer. A fresh UtcTimer starts with time_sec = 0, so before this + // fix the saved TX Delay loaded into the running signal was silently wiped + // and the offset fell back to 0 ms until the operator re-edited the value — + // the reported "TX Delay is not applied until I change its value" bug. The + // rebuild must carry the outgoing timer's offset onto the new one. + // + // UtcTimer construction only schedules java.util.Timer tasks (no JNI / no + // Android framework), so this runs on the bare JVM; delete() the timers to + // avoid leaking their heartbeat threads between tests. + + private static final OnUtcTimer NOOP_CALLBACK = new OnUtcTimer() { + @Override public void doHeartBeatTimer(long utc) { } + @Override public void doOnSecTimer(long utc) { } + }; + + @Test + public void rebuildTimer_carriesOffsetOntoNewTimer() { + UtcTimer old = new UtcTimer(ModeProfile.FT8.slotMillis, false, NOOP_CALLBACK); + try { + old.setTime_sec(1800); // saved TX Delay, in ms + UtcTimer rebuilt = FT8TransmitSignal.rebuildTimerPreservingOffset( + old, ModeProfile.FT4, NOOP_CALLBACK); + try { + // The core fix: the offset survives the rebuild instead of resetting to 0. + assertThat(rebuilt.getTime_sec()).isEqualTo(1800); + } finally { + rebuilt.delete(); + } + } finally { + old.delete(); + } + } + + @Test + public void rebuildTimer_zeroOffsetStaysZero() { + // No TX Delay set: the rebuild must not invent one. + UtcTimer old = new UtcTimer(ModeProfile.FT8.slotMillis, false, NOOP_CALLBACK); + try { + UtcTimer rebuilt = FT8TransmitSignal.rebuildTimerPreservingOffset( + old, ModeProfile.FT8, NOOP_CALLBACK); + try { + assertThat(rebuilt.getTime_sec()).isEqualTo(0); + } finally { + rebuilt.delete(); + } + } finally { + old.delete(); + } + } } From 50510c5bd63895cef596353893c03e9da21b475a Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 14:36:29 +0000 Subject: [PATCH 020/113] Export date picker: expose calendar as an accessibility action Address Copilot review: the trailing calendar icon is a touch-only hit target on the EditText's compound drawable, so TalkBack users could not activate the picker. Register a ViewCompat custom accessibility action ("Open calendar date picker") on each field so the picker path is operable via accessibility services, not just touch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/ui/ExportLogSheet.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java index 16b8d9f1d..fd87347b6 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java @@ -14,6 +14,8 @@ import android.widget.ProgressBar; import android.widget.TextView; +import androidx.core.view.ViewCompat; +import androidx.core.view.accessibility.AccessibilityViewCommand; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; @@ -243,6 +245,18 @@ public boolean onTouch(View v, MotionEvent event) { return false; } }); + // The trailing calendar icon is a touch-only hit target, so also expose the + // picker as a custom accessibility action — TalkBack users get an "Open + // calendar" entry in the field's actions menu rather than only being able + // to type. + ViewCompat.addAccessibilityAction(field, "Open calendar date picker", + new AccessibilityViewCommand() { + @Override + public boolean perform(View view, AccessibilityViewCommand.CommandArguments args) { + openDatePicker(field, tag); + return true; + } + }); } private void openDatePicker(final EditText field, String tag) { From 7c3904c321c9a4bb604a6541435a61f5e9dd503c Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 14:42:38 +0000 Subject: [PATCH 021/113] Fix distorted USB-direct TX audio: band-limit the 12k->48k upsampler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The USB-direct (libusb) TX path in UsbAudioDevice.writeAudio() upsampled the 12 kHz FT8 waveform to the device's 48 kHz rate with naive linear interpolation. A linear interpolator convolves with a triangular kernel (sinc^2 response), which only lightly attenuates the spectral images of the 12 kHz-sampled tone. For a ~1500 Hz FT8 tone those images land at 10.5/13.5 kHz — inside the 48 kHz output band — and ride into the radio's modulator as audible harmonic distortion on TX (Yaesu FT-710 report). The phone-speaker path stays clean because the OS USB driver resamples with a proper band-limited filter; only the app's own direct-libusb path used the crude interpolator. Replace it with TxUpsampler, which reuses the host-tested Blackman-windowed-sinc polyphase kernel already used on the capture side (RationalResampler): exact L/M rational resampling with a stopband well below FT8's ~3 kHz top, group-delay compensated so the leading Costas sync array is not shifted or clipped. Images are now rejected by >40 dB. Adds TxUpsamplerTest covering length, frequency/amplitude preservation, image rejection vs. the old linear path, the 44.1 kHz non-integer ratio, and degenerate-rate guards. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/wave/TxUpsampler.java | 75 +++++++++ .../com/k1af/ft8af/wave/UsbAudioDevice.java | 28 +--- .../com/k1af/ft8af/wave/TxUpsamplerTest.java | 144 ++++++++++++++++++ 3 files changed, 227 insertions(+), 20 deletions(-) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/wave/TxUpsampler.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/wave/TxUpsamplerTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/TxUpsampler.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/TxUpsampler.java new file mode 100644 index 000000000..6dc27bad4 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/TxUpsampler.java @@ -0,0 +1,75 @@ +package com.k1af.ft8af.wave; + +/** + * Band-limited one-shot resampler for the USB-direct (libusb) TX output path in + * {@link UsbAudioDevice#writeAudio}. + * + *

Why it exists (distorted TX audio with the Yaesu FT-710): the FT8 waveform is generated at + * 12 kHz and the USB audio device streams at 48 kHz, so writeAudio() must upsample 4x. The old + * path did this with naive linear interpolation. A linear interpolator is a convolution with a + * triangular kernel, whose frequency response ({@code sinc^2}) only lightly attenuates the + * spectral images of the 12 kHz-sampled tone. For a ~1500 Hz FT8 tone the first images land at + * {@code 12000 - 1500 = 10500} Hz and {@code 12000 + 1500 = 13500} Hz — inside the 48 kHz output + * band — and ride into the radio's modulator as harmonic distortion. The phone-speaker path + * stays clean because Android/the OS USB driver resamples it with a proper band-limited filter; + * only the app's own direct-libusb path used the crude interpolator. + * + *

The fix reuses the same Blackman-windowed-sinc polyphase kernel already used (and + * host-tested) on the capture side ({@link RationalResampler}): interpolate by L, low-pass, + * decimate by M for the exact reduced ratio {@code toRate/fromRate}. The stopband sits well below + * FT8's ~3 kHz top, so the images are rejected by > 40 dB and the transmitted tone is clean. + * Covered by {@code TxUpsamplerTest}. + */ +public final class TxUpsampler { + + private TxUpsampler() {} + + /** + * Resample mono full-scale float PCM from {@code fromRate} to {@code toRate} with a + * band-limited polyphase filter. Returns a buffer of exactly {@code input.length * L / M} + * samples, where {@code L/M} is the reduced {@code toRate/fromRate} ratio, group-delay + * compensated so output sample 0 lines up with input sample 0 (the FT8 leading Costas sync + * array must not be shifted or clipped). Returns the input array unchanged when the rates + * match or either rate is non-positive. + */ + public static float[] resample(float[] input, int fromRate, int toRate) { + if (fromRate <= 0 || toRate <= 0 || fromRate == toRate || input.length == 0) { + return input; + } + + final int g = gcd(toRate, fromRate); + final int l = toRate / g; // interpolation factor + final int m = fromRate / g; // decimation factor + + final RationalResampler rs = new RationalResampler(l, m); + final int targetLen = (int) ((long) input.length * l / m); + + // The FIR is symmetric, so the output lags the input by (numTaps - 1) / 2 upsampled + // samples, i.e. (numTaps - 1) / (2 * M) output samples. Push that many extra output + // samples out by feeding trailing zeros, then drop them off the front so output[0] + // corresponds to input[0] rather than to the filter's ramp-up. + final int groupDelayOut = (rs.numTaps() - 1) / (2 * m); + final int flushInputs = (int) Math.ceil((double) groupDelayOut * m / l) + 2; + final int fedLen = input.length + flushInputs; + + final float[] fed = java.util.Arrays.copyOf(input, fedLen); // pads with trailing zeros + final float[] tmp = new float[rs.maxOutputFor(fedLen)]; + final int produced = rs.process(fed, fedLen, tmp); + + final float[] out = new float[targetLen]; + final int copy = Math.min(targetLen, produced - groupDelayOut); + if (copy > 0) { + System.arraycopy(tmp, groupDelayOut, out, 0, copy); + } + return out; + } + + private static int gcd(int a, int b) { + while (b != 0) { + int t = a % b; + a = b; + b = t; + } + return a; + } +} diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java index 7b5ef8796..357e127b7 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java @@ -917,28 +917,16 @@ public boolean writeAudio(float[] audioData, int sourceSampleRate) { } /** - * Linear interpolation resampler. + * Band-limited TX resampler (12 kHz FT8 generator rate -> the device's 48 kHz output rate). + * + *

Delegates to {@link TxUpsampler}, which uses the same polyphase windowed-sinc kernel as + * the capture path. The previous naive linear interpolator left the 12 kHz sampling images + * (e.g. 10.5/13.5 kHz for a 1500 Hz tone) only lightly attenuated, which the radio's + * modulator turned into audible harmonic distortion on TX even though the OS-resampled phone + * speaker stayed clean. */ private float[] resample(float[] input, int fromRate, int toRate) { - if (fromRate == toRate) return input; - - double ratio = (double) toRate / fromRate; - int outputLen = (int) (input.length * ratio); - float[] output = new float[outputLen]; - - for (int i = 0; i < outputLen; i++) { - double srcIndex = i / ratio; - int idx = (int) srcIndex; - double frac = srcIndex - idx; - - if (idx + 1 < input.length) { - output[i] = (float) (input[idx] * (1 - frac) + input[idx + 1] * frac); - } else if (idx < input.length) { - output[i] = input[idx]; - } - } - - return output; + return TxUpsampler.resample(input, fromRate, toRate); } public void close() { diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/wave/TxUpsamplerTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/wave/TxUpsamplerTest.java new file mode 100644 index 000000000..52538ac97 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/TxUpsamplerTest.java @@ -0,0 +1,144 @@ +package com.k1af.ft8af.wave; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Guards the band-limited TX upsampler used by the USB-direct (libusb) output path in + * {@link UsbAudioDevice#writeAudio}. The FT8 waveform is generated at 12 kHz and the USB audio + * device streams at 48 kHz, so writeAudio() must upsample 4x. + * + *

The bug this covers (distorted TX audio with the Yaesu FT-710): the old path used naive + * linear interpolation, whose triangular kernel leaves the spectral images of the 12 kHz-sampled + * tone only lightly attenuated. For a ~1500 Hz FT8 tone those images land at 12000 - 1500 = + * 10500 Hz and 12000 + 1500 = 13500 Hz — inside the 48 kHz output band — and ride into the + * radio's modulator as harmonic distortion, even though the OS-resampled phone-speaker path stays + * clean. {@link TxUpsampler} replaces the linear interpolator with the same Blackman-windowed-sinc + * polyphase kernel already used on the capture side, which rejects those images. + */ +public class TxUpsamplerTest { + + private static final int SRC_RATE = 12000; // FT8 generator rate + private static final int DST_RATE = 48000; // USB audio device rate + + private static float[] tone(int rate, double freqHz, double seconds) { + int n = (int) (rate * seconds); + float[] in = new float[n]; + for (int i = 0; i < n; i++) { + in[i] = (float) Math.sin(2.0 * Math.PI * freqHz * i / rate); + } + return in; + } + + /** Naive linear-interpolation upsampler — the code being replaced, kept here as a control. */ + private static float[] linearResample(float[] input, int fromRate, int toRate) { + if (fromRate == toRate) return input; + double ratio = (double) toRate / fromRate; + int outputLen = (int) (input.length * ratio); + float[] output = new float[outputLen]; + for (int i = 0; i < outputLen; i++) { + double srcIndex = i / ratio; + int idx = (int) srcIndex; + double frac = srcIndex - idx; + if (idx + 1 < input.length) { + output[i] = (float) (input[idx] * (1 - frac) + input[idx + 1] * frac); + } else if (idx < input.length) { + output[i] = input[idx]; + } + } + return output; + } + + /** Single-bin magnitude via Goertzel, normalized so a unit-amplitude tone reads ~1.0. */ + private static double toneMag(float[] x, double freqHz, int rate) { + double w = 2.0 * Math.PI * freqHz / rate; + double cw = Math.cos(w); + double sw = Math.sin(w); + double coeff = 2.0 * cw; + double s1 = 0.0; + double s2 = 0.0; + for (float v : x) { + double s0 = v + coeff * s1 - s2; + s2 = s1; + s1 = s0; + } + double re = s1 - s2 * cw; + double im = s2 * sw; + return Math.sqrt(re * re + im * im) / (x.length / 2.0); + } + + /** Dominant frequency of the back half via zero-crossing count (clean single tone). */ + private static double zeroCrossFreq(float[] out, int outRate) { + int start = out.length / 2; + int crossings = 0; + for (int i = start + 1; i < out.length; i++) { + if ((out[i - 1] < 0f) != (out[i] < 0f)) crossings++; + } + double seconds = (double) (out.length - start) / outRate; + return crossings / (2.0 * seconds); + } + + private static float steadyPeak(float[] out) { + float peak = 0f; + for (int i = out.length / 2; i < out.length; i++) { + peak = Math.max(peak, Math.abs(out[i])); + } + return peak; + } + + @Test + public void passesThroughWhenRatesEqual() { + float[] in = tone(SRC_RATE, 1000.0, 0.1); + assertThat(TxUpsampler.resample(in, SRC_RATE, SRC_RATE)).isSameInstanceAs(in); + } + + @Test + public void quadruplesLengthFor12kTo48k() { + float[] in = tone(SRC_RATE, 1500.0, 1.0); + float[] out = TxUpsampler.resample(in, SRC_RATE, DST_RATE); + assertThat(out.length).isEqualTo(in.length * 4); + } + + @Test + public void preservesToneFrequencyAndAmplitude() { + float[] out = TxUpsampler.resample(tone(SRC_RATE, 1500.0, 1.0), SRC_RATE, DST_RATE); + assertThat(zeroCrossFreq(out, DST_RATE)).isWithin(5.0).of(1500.0); + assertThat((double) steadyPeak(out)).isWithin(0.05).of(1.0); + } + + @Test + public void rejectsSpectralImagesThatLinearInterpolationLeaves() { + float[] in = tone(SRC_RATE, 1500.0, 1.0); + + // Control: naive linear interpolation leaves a clearly audible image at 12000 - 1500 Hz. + float[] linear = linearResample(in, SRC_RATE, DST_RATE); + double linearFund = toneMag(linear, 1500.0, DST_RATE); + double linearImage = toneMag(linear, 10500.0, DST_RATE); + // The image the field bug is about is real and non-trivial on the old path. + assertThat(linearImage / linearFund).isGreaterThan(0.02); + + // Band-limited polyphase upsampler: same fundamental, image rejected by > 40 dB. + float[] out = TxUpsampler.resample(in, SRC_RATE, DST_RATE); + double fund = toneMag(out, 1500.0, DST_RATE); + double image = toneMag(out, 10500.0, DST_RATE); + assertThat((double) fund).isWithin(0.05).of(1.0); + assertThat(image / fund).isLessThan(0.01); + } + + @Test + public void handlesNonIntegerRatio44100() { + // A device that only streams 44.1 kHz reduces to L/M = 147/40; the tone must survive. + float[] in = tone(SRC_RATE, 1500.0, 1.0); + float[] out = TxUpsampler.resample(in, SRC_RATE, 44100); + assertThat(out.length).isEqualTo((int) ((long) in.length * 147 / 40)); + assertThat(zeroCrossFreq(out, 44100)).isWithin(5.0).of(1500.0); + } + + @Test + public void guardsDegenerateRates() { + float[] in = tone(SRC_RATE, 1000.0, 0.05); + assertThat(TxUpsampler.resample(in, 0, DST_RATE)).isSameInstanceAs(in); + assertThat(TxUpsampler.resample(in, SRC_RATE, 0)).isSameInstanceAs(in); + } +} From 99781a1c78b2b0e46567af73463b02056e99ac51 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 09:43:44 -0500 Subject: [PATCH 022/113] Review fixes: log the playback failure with its stack trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Log.e(TAG, msg, e) instead of string-concatenating the exception, so the full stack survives into logcat for field debugging. - Correct the Javadoc: the helper is not "free of Android types" — it calls android.util.Log, which is a returnDefaultValues stub in unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java index 0cb6bc673..c608f9a12 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java @@ -906,13 +906,16 @@ private void playViaAudioTrack(float[] buffer) { * never acceptable. Swallow-and-log the failure and always tear down, * mirroring {@link #playTuneTone}. * - *

Package-visible and free of Android types for testing. + *

Package-visible for testing. Its parameters are plain {@link Runnable}s, + * so a test can drive it with no Android dependency beyond the + * {@link Log} call below — which is a no-op stub under the module's + * {@code unitTests.returnDefaultValues} setting. */ static void runPlaybackWithTeardown(Runnable body, Runnable teardown) { try { body.run(); } catch (Exception e) { - Log.e(TAG, "FT8 AudioTrack playback failed: " + e); + Log.e(TAG, "FT8 AudioTrack playback failed", e); } finally { teardown.run(); } From 23cf3c2c6b5bf5b04b55d8518f782143ed98cd62 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 09:46:40 -0500 Subject: [PATCH 023/113] Review fix: retry hamlib_feed_write on EINTR instead of truncating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A write() interrupted by a signal leaves the fd healthy, but the loop broke on any write() <= 0 — so a signal landing on the CAT read thread mid-write silently dropped the rest of the rig's reply and hamlib saw a short frame. Retry on EINTR; every other non-positive return still ends the loop. New host test case 6 fills the pipe to capacity (the only state where the kernel reports -1/EINTR rather than a short write) and interrupts the blocked write with a repeating SIGALRM installed without SA_RESTART. It fails on the pre-fix loop and passes on the fixed one. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/src/main/cpp/ft8af_glue/hamlib_feed.h | 20 ++- .../main/cpp/ft8af_glue/test_hamlib_feed.c | 119 ++++++++++++++++++ 2 files changed, 134 insertions(+), 5 deletions(-) diff --git a/ft8af/app/src/main/cpp/ft8af_glue/hamlib_feed.h b/ft8af/app/src/main/cpp/ft8af_glue/hamlib_feed.h index 3e563a085..e7f136c1d 100644 --- a/ft8af/app/src/main/cpp/ft8af_glue/hamlib_feed.h +++ b/ft8af/app/src/main/cpp/ft8af_glue/hamlib_feed.h @@ -21,21 +21,27 @@ // This header collapses the write loop into one null-safe, host-tested // implementation (test_hamlib_feed.c). It takes plain POSIX types (no JNI // dependency) so it links with no NDK. The behaviour is identical to the old -// inline loop for every valid buffer; the only change is that a NULL pointer, a -// non-positive length, or a closed socket (fd < 0) is now a no-op instead of a -// crash. +// inline loop for every valid buffer, save two hardening changes: a NULL +// pointer, a non-positive length, or a closed socket (fd < 0) is now a no-op +// instead of a crash, and a write() interrupted by a signal (EINTR) is retried +// instead of ending the loop — the old loop treated that as "socket gone" and +// silently dropped the rest of a CAT reply on a still-healthy fd. #ifndef FT8AF_HAMLIB_FEED_H #define FT8AF_HAMLIB_FEED_H +#include #include #include #include // Write all `len` bytes of `bytes` to socket `fd`, tolerating short writes // (loops until the whole buffer is sent). A NULL `bytes`, a non-positive `len`, -// or a negative `fd` is a no-op — never a dereference. Stops early if write() -// returns <= 0 (closed/errored socket), mirroring the original loop. +// or a negative `fd` is a no-op — never a dereference. A write() interrupted by +// a signal (EINTR) is retried — the fd is still healthy, so bailing there would +// silently truncate a CAT reply. Stops early only when write() actually fails or +// the peer is gone (return <= 0 for any other reason), mirroring the original +// loop. // // Returns the number of bytes actually written (0 on any of the guarded cases). static inline long hamlib_feed_write(int fd, const unsigned char *bytes, long len) @@ -48,7 +54,11 @@ static inline long hamlib_feed_write(int fd, const unsigned char *bytes, long le { ssize_t w = write(fd, bytes + off, (size_t)(len - off)); if (w <= 0) + { + if (w < 0 && errno == EINTR) + continue; // interrupted before any byte moved — retry, don't truncate break; + } off += w; } return off; diff --git a/ft8af/app/src/main/cpp/ft8af_glue/test_hamlib_feed.c b/ft8af/app/src/main/cpp/ft8af_glue/test_hamlib_feed.c index 8beaf2a70..c79fd7898 100644 --- a/ft8af/app/src/main/cpp/ft8af_glue/test_hamlib_feed.c +++ b/ft8af/app/src/main/cpp/ft8af_glue/test_hamlib_feed.c @@ -15,15 +15,23 @@ // This test passes NULL and can only pass if the pointer is guarded first. // 4. A non-positive length is a no-op returning 0. // 5. A negative fd (closed socket) is a no-op returning 0 — no write attempt. +// 6. REGRESSION: a write() interrupted by a signal (EINTR) must be retried, +// not treated as a dead socket. The loop originally broke on any write() +// <= 0, so a signal landing on the CAT read thread mid-write truncated the +// rest of the rig's reply on a perfectly healthy fd — hamlib then saw a +// short frame and timed out. Driven here with a repeating SIGALRM installed +// *without* SA_RESTART while the write blocks on a full pipe. #include #include #include +#include #include #include #include #include #include +#include #include #include "hamlib_feed.h" @@ -62,6 +70,47 @@ static void* reader_main(void* arg) return NULL; } +// --- EINTR harness ------------------------------------------------------- +// Counts SIGALRM deliveries so the EINTR test can prove the timer really did +// interrupt the blocked write (rather than passing vacuously). +static volatile sig_atomic_t g_alarms = 0; + +static void on_alarm(int sig) +{ + (void)sig; + g_alarms++; +} + +// Reader that deliberately stays idle for `delay_ms` first, so a write into an +// already-full pipe is guaranteed to be blocking when the alarm lands. +struct slow_reader_arg { + int fd; + long total; + long got; + unsigned int delay_ms; +}; + +static void* slow_reader_main(void* arg) +{ + struct slow_reader_arg* r = (struct slow_reader_arg*)arg; + usleep(r->delay_ms * 1000u); + unsigned char buf[4096]; + while (r->got < r->total) + { + ssize_t n = read(r->fd, buf, sizeof(buf)); + if (n < 0) + { + if (errno == EINTR) + continue; + break; + } + if (n == 0) + break; // EOF: write end closed + r->got += n; + } + return NULL; +} + // Write `len` bytes through hamlib_feed_write and collect what the reader saw. // Returns the value hamlib_feed_write returned; fills *out_got with bytes read. static long feed_and_collect(const unsigned char* data, long len, @@ -142,6 +191,76 @@ int main(void) check(hamlib_feed_write(-1, msg, 3) == 0, "negative fd is a no-op"); } + // 6. REGRESSION: a write() interrupted by a signal must be retried. + // Fill the pipe to capacity first, so hamlib_feed_write's write() blocks + // with zero bytes transferable — the only state in which the kernel + // reports -1/EINTR rather than a short write. A repeating SIGALRM + // installed WITHOUT SA_RESTART then interrupts it. The old loop broke on + // any write() <= 0, so it returned 0 here and dropped the whole frame. + { + int fds[2]; + if (pipe(fds) != 0) { perror("pipe"); return 2; } + + // Fill the write end (non-blocking) until the kernel buffer is full. + int flags = fcntl(fds[1], F_GETFL, 0); + fcntl(fds[1], F_SETFL, flags | O_NONBLOCK); + unsigned char pad[4096]; + memset(pad, 0x5A, sizeof(pad)); + long filled = 0; + for (;;) + { + ssize_t n = write(fds[1], pad, sizeof(pad)); + if (n <= 0) + break; // EAGAIN: pipe is full + filled += n; + } + fcntl(fds[1], F_SETFL, flags); // back to blocking + + const unsigned char msg[] = { 0xFE, 0xFE, 0x00, 0x03, 0xFD }; + struct slow_reader_arg r = { fds[0], filled + (long)sizeof(msg), 0, 150 }; + + // SIGALRM must land on this (writing) thread, not the reader: block it + // before the reader is created — it inherits the mask — then unblock here. + sigset_t alarm_set, prev_mask; + sigemptyset(&alarm_set); + sigaddset(&alarm_set, SIGALRM); + pthread_sigmask(SIG_BLOCK, &alarm_set, &prev_mask); + pthread_t th; + pthread_create(&th, NULL, slow_reader_main, &r); + + struct sigaction sa, old_sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = on_alarm; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; // deliberately NOT SA_RESTART — we want EINTR + sigaction(SIGALRM, &sa, &old_sa); + struct itimerval tick; + tick.it_value.tv_sec = 0; + tick.it_value.tv_usec = 20000; // first alarm well inside the 150 ms idle + tick.it_interval.tv_sec = 0; + tick.it_interval.tv_usec = 20000; // and keep interrupting + setitimer(ITIMER_REAL, &tick, NULL); + pthread_sigmask(SIG_UNBLOCK, &alarm_set, NULL); + + long wrote = hamlib_feed_write(fds[1], msg, (long)sizeof(msg)); + + struct itimerval stop; + memset(&stop, 0, sizeof(stop)); + setitimer(ITIMER_REAL, &stop, NULL); + sigaction(SIGALRM, &old_sa, NULL); + pthread_sigmask(SIG_SETMASK, &prev_mask, NULL); + + close(fds[1]); // EOF for the reader + pthread_join(th, NULL); + close(fds[0]); + + check(g_alarms > 0, "EINTR case: the timer really did fire during the blocked write"); + check(wrote == (long)sizeof(msg), + "write interrupted by a signal is retried, not truncated"); + check(r.got == filled + (long)sizeof(msg), + "every byte still reaches the reader after an EINTR"); + } + if (g_failures == 0) { printf("test_hamlib_feed: ALL PASS\n"); From ee0b50c2d0853f25856091dce1e5820e8c7a9c0e Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 09:47:22 -0500 Subject: [PATCH 024/113] Review fix: correct the getGridSquare(LatLng) Javadoc It described a 6-character grid from NMEA-format coordinates; the method takes decimal-degree LatLng values and returns a 4-character locator. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/maidenhead/MaidenheadGrid.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/maidenhead/MaidenheadGrid.java b/ft8af/app/src/main/java/com/k1af/ft8af/maidenhead/MaidenheadGrid.java index 58fb75a60..0cb787067 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/maidenhead/MaidenheadGrid.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/maidenhead/MaidenheadGrid.java @@ -210,11 +210,15 @@ public static LatLng[] gridToPolygon(String grid) { } /** - * This function calculates a 6-character Maidenhead grid from latitude/longitude. - * Latitude/longitude use NMEA format. In other words, west longitude and south latitude are negative. They are specified as double type. + * Calculates the 4-character Maidenhead grid (field pair + square pair, e.g. {@code "EM48"}) + * containing the given position. Latitude/longitude are decimal degrees, signed: west + * longitude and south latitude are negative. * - * @param location latitude/longitude - * @return String Maidenhead grid string + *

Boundary coordinates are clamped to a legal locator — see {@link #gridSquareFor} for + * the math and why the clamp matters. + * + * @param location latitude/longitude in decimal degrees + * @return 4-character Maidenhead grid string */ public static String getGridSquare(LatLng location) { return gridSquareFor(location.latitude, location.longitude); From 85bbd7801626d5797f509ce1d6ed885515847a46 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 09:50:23 -0500 Subject: [PATCH 025/113] Review fix: discard rejected submits only while the pool is shutting down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiscardPolicy drops every rejection, so a rejection during normal operation (e.g. thread creation failing under resource exhaustion) would silently swallow the cycle/heartbeat callback. DiscardOnShutdownPolicy discards only when executor.isShutdown() — the teardown race this fix targets — and delegates everything else to AbortPolicy, keeping real failures visible. New test: a saturated still-running pool with the policy installed still throws RejectedExecutionException. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/timer/UtcTimer.java | 32 +++++++++++++++++-- .../com/k1af/ft8af/timer/UtcTimerTest.java | 32 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/timer/UtcTimer.java b/ft8af/app/src/main/java/com/k1af/ft8af/timer/UtcTimer.java index 1cbc6bb5b..f7b6f36e6 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/timer/UtcTimer.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/timer/UtcTimer.java @@ -28,6 +28,7 @@ import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionHandler; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -285,15 +286,42 @@ public void delete() { * submit is the correct behaviour during teardown (the cycle/heartbeat callback is moot once we * are shutting down) and is a no-op in normal operation: with an unbounded maximum pool size over * a {@link SynchronousQueue}, a submit is only ever rejected once the pool has been shut down. + * + *

The rejection handler discards only while the pool is shutting down. A rejection + * for any other reason (e.g. the JVM/OS refusing a new thread under resource exhaustion) still + * aborts with the usual {@code RejectedExecutionException} rather than silently swallowing the + * callback, so a real failure stays visible instead of turning into a mysteriously dead + * heartbeat. */ static ThreadPoolExecutor newDiscardingCachedThreadPool() { - // Mirrors Executors.newCachedThreadPool() exactly, then swaps in DiscardPolicy. + // Mirrors Executors.newCachedThreadPool() exactly, then swaps in the teardown policy. ThreadPoolExecutor pool = new ThreadPoolExecutor( 0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue()); - pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); + pool.setRejectedExecutionHandler(new DiscardOnShutdownPolicy()); return pool; } + /** + * Discards a task rejected because the executor is shutting down; delegates every other + * rejection to {@link ThreadPoolExecutor.AbortPolicy}. + * + *

{@link ThreadPoolExecutor.DiscardPolicy} would drop all rejections, including one + * caused by thread-creation failure during normal operation — a silent loss of the cycle or + * heartbeat callback with nothing in the log to explain it. Only the teardown race that + * {@link #newDiscardingCachedThreadPool()} exists to fix is safe to ignore. + */ + static final class DiscardOnShutdownPolicy implements RejectedExecutionHandler { + private static final ThreadPoolExecutor.AbortPolicy ABORT = new ThreadPoolExecutor.AbortPolicy(); + + @Override + public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { + if (executor.isShutdown()) { + return; // teardown in progress: the cycle/heartbeat callback is moot + } + ABORT.rejectedExecution(r, executor); + } + } + /** * Set the time offset; positive values shift forward * diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/timer/UtcTimerTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/timer/UtcTimerTest.java index e19d7129b..2b66ca3bc 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/timer/UtcTimerTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/timer/UtcTimerTest.java @@ -347,6 +347,38 @@ public void discardingPool_stillRunsSubmittedWorkBeforeShutdown() throws Excepti } } + @Test + public void discardOnShutdownPolicy_stillAbortsWhenRejectionIsNotShutdown() throws Exception { + // Only the teardown race is safe to swallow. A rejection while the pool is still + // running (here: a saturated bounded pool standing in for a thread-creation + // failure) must surface as RejectedExecutionException rather than silently + // dropping the cycle/heartbeat callback with nothing in the log. + ThreadPoolExecutor bounded = new ThreadPoolExecutor( + 1, 1, 0L, java.util.concurrent.TimeUnit.SECONDS, + new java.util.concurrent.SynchronousQueue()); + bounded.setRejectedExecutionHandler(new UtcTimer.DiscardOnShutdownPolicy()); + final java.util.concurrent.CountDownLatch occupied = new java.util.concurrent.CountDownLatch(1); + final java.util.concurrent.CountDownLatch release = new java.util.concurrent.CountDownLatch(1); + try { + bounded.execute(() -> { + occupied.countDown(); + try { + release.await(); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + }); + assertThat(occupied.await(5, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + // The single worker is busy and the SynchronousQueue has no capacity, so this + // submit is rejected while the pool is very much still running. + assertThat(bounded.isShutdown()).isFalse(); + assertThrows(RejectedExecutionException.class, () -> bounded.execute(() -> { })); + } finally { + release.countDown(); + bounded.shutdownNow(); + } + } + @Test public void ntpClockOffsetMs_clampsAbsurdErrorsToIntRange() { // A wildly wrong device clock (> ~24.8 days off) must not overflow the int From eda172774d6108d68d9fa4d73aabc532878a3059 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 14:52:05 +0000 Subject: [PATCH 026/113] Fix reflected/stored XSS in web-logbook via central htmlEscape() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LogHttpServer (NanoHTTPD on port 7050, bound to all interfaces with no auth) echoed untrusted query params and user-controlled DB fields into generated HTML with no or incomplete escaping. The ad-hoc `.replace("<", "<")` calls escaped only `<`/`>`, so a `"` still broke out of a `value="…"` attribute — e.g. `?callsign=">"); + assertThat(escaped).isEqualTo("<script>alert(1)</script>"); + assertThat(escaped).doesNotContain(""); + // the closing quote that used to break out of the attribute is gone + assertThat(escaped).doesNotContain("\""); + assertThat(escaped).doesNotContain("<"); + assertThat(escaped).doesNotContain(">"); + assertThat(escaped).isEqualTo( + ""><script>alert(document.cookie)</script>"); + } + + @Test + public void singleQuoteAttributeBreakoutNeutralised() { + // single-quoted attribute breakout: value='…' with a ' payload + String escaped = HtmlContext.htmlEscape("' onmouseover='alert(1)"); + assertThat(escaped).doesNotContain("'"); + assertThat(escaped).isEqualTo("' onmouseover='alert(1)"); + } + + @Test + public void mixedContentEscapedInPlace() { + assertThat(HtmlContext.htmlEscape("a & b < c > d \" e ' f")) + .isEqualTo("a & b < c > d " e ' f"); + } +} From ef0fc06d9983bc474769f04a2395f9d98ff690fc Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 09:52:13 -0500 Subject: [PATCH 027/113] Review fix: fail the LruCache stress test if a worker doesn't terminate The join timeouts were ignored and `stop` was only set after joining, so a stalled worker could let the test pass while leaking a live thread. Workers are now daemons, the joins share one 30s deadline, any thread still alive at that point is asserted as a failure, and `stop` is used only as the fallback that asks the loops to bail out. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../radio/ks3ckc/ft8af/qrz/LruCacheTest.kt | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/qrz/LruCacheTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/qrz/LruCacheTest.kt index 24141fa4e..c67833593 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/qrz/LruCacheTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/qrz/LruCacheTest.kt @@ -1,6 +1,7 @@ package radio.ks3ckc.ft8af.qrz import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import org.junit.Test import java.util.Collections import java.util.concurrent.CountDownLatch @@ -126,13 +127,33 @@ class LruCacheTest { } catch (th: Throwable) { failures.add(th) } - }.also { it.start() } + }.also { + // Daemon so a genuinely wedged worker can never keep the JVM (and the + // Gradle test worker) alive after this test has reported its verdict. + it.isDaemon = true + it.start() + } } start.countDown() - workers.forEach { it.join(TimeUnit.SECONDS.toMillis(30)) } - stop.set(true) + // One shared deadline across all joins, not 30s per thread. A worker still alive + // when it expires is a failure, not something to shrug off: `stop` is only the + // fallback that asks the loops to bail out so the test doesn't leave them running. + val deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(30) + val stalled = workers.filter { worker -> + val remainingMs = TimeUnit.NANOSECONDS.toMillis(deadlineNanos - System.nanoTime()) + if (remainingMs > 0) worker.join(remainingMs) + worker.isAlive + } + if (stalled.isNotEmpty()) { + stop.set(true) + stalled.forEach { it.join(TimeUnit.SECONDS.toMillis(5)) } + } + + assertWithMessage("workers still running after the join deadline") + .that(stalled.map { it.name }) + .isEmpty() assertThat(failures).isEmpty() } } From f447fefedfdf868b0290c691cdb21eaea4974902 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 14:53:04 +0000 Subject: [PATCH 028/113] Add "same mode only" refinement to worked-station scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worked-station handling (Settings → Decode Highlights) lets the operator pick which stations count as worked — on this band, worked before anywhere, worked today, or from a list — and what to do with them (highlight / ignore / hide). The feature request additionally asked for the "…on this band and mode" variants, i.e. only treating a station as worked when the earlier QSO was on the same mode you're operating. This adds that as an orthogonal "Same mode only" toggle. When on, WorkedModeFilter appends an `upper(mode) = ?` predicate to the worked lists loaded in DatabaseOpr.GetAllQSLCallsign (current-band, other-band and today), so a station worked only on a different mode (e.g. FT4 while you're on FT8) still shows as new. The filtering happens at list-load time, so every scope that reads those lists honours it automatically; FROM_LIST is user-maintained and unaffected. WorkedModeFilter is a plain, side-effect-free helper so the predicate is unit-testable without Robolectric. The setting is persisted via writeConfig("workedSameMode", …), hydrated in the config loader, and the worked lists reload immediately when it changes. The toggle is only offered for the band/before/today scopes. Tests: WorkedModeFilterTest (pure predicate/arg logic) and GetAllQSLCallsignModeTest (Robolectric, drives real SQLite to confirm the refinement filters all three worked lists). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/GeneralVariables.java | 5 ++ .../com/k1af/ft8af/database/DatabaseOpr.java | 25 ++++-- .../k1af/ft8af/database/WorkedModeFilter.java | 64 ++++++++++++++ .../ft8af/ui/settings/DecodeFilterSettings.kt | 20 +++++ .../src/main/res/values/strings_compose.xml | 2 + .../database/GetAllQSLCallsignModeTest.java | 87 +++++++++++++++++++ .../ft8af/database/WorkedModeFilterTest.java | 45 ++++++++++ 7 files changed, 240 insertions(+), 8 deletions(-) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/database/WorkedModeFilter.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/database/GetAllQSLCallsignModeTest.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/database/WorkedModeFilterTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 65bd69302..631377fe0 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -534,6 +534,11 @@ public static String excludedBandsToCsv() { // 3=FROM_LIST (present in the user-maintained worked-station list). public static int workedStationMode = 0; // HIGHLIGHT — preserves legacy behavior public static int workedStationScope = 0; // ON_BAND — preserves legacy behavior + // Orthogonal "same mode only" refinement (config "workedSameMode", default off): + // when on, the worked lists loaded by DatabaseOpr.GetAllQSLCallsign only count + // QSOs made on the current operating mode, giving the "…on this band and mode" + // variants of the scopes above. FROM_LIST is user-maintained and unaffected. + public static boolean workedSameMode = false; // User-maintained "worked" callsign list backing the FROM_LIST scope. Upper-cased // whole-call tokens, same parse/join convention as the callsign blocklist. private static final java.util.LinkedHashSet workedStationList = new java.util.LinkedHashSet<>(); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 44480629c..db1adee6b 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2344,11 +2344,18 @@ protected Void doInBackground(Void... voids) { static class GetAllQSLCallsign { public static void get(SQLiteDatabase db) { + // "Same mode only" refinement (Settings → Decode Highlights): when on, + // restrict every worked list to QSOs made on the current operating mode, + // so the "and mode" variants of the worked-station scopes work. FROM_LIST + // is user-maintained and unaffected. See WorkedModeFilter. + String meter = BaseRigOperation.getMeterFromFreq(GeneralVariables.band); + String mode = com.k1af.ft8af.ModeProfile.fromId(GeneralVariables.operatingMode).displayName; + WorkedModeFilter modeFilter = WorkedModeFilter.build(GeneralVariables.workedSameMode, mode); + //String querySQL = "select distinct [call] from QSLTable where freq=?"; //Changed to get contacted callsigns by band wavelength - String querySQL = "select distinct [call] from QSLTable where band=?"; - Cursor cursor = db.rawQuery(querySQL, new String[]{ - BaseRigOperation.getMeterFromFreq(GeneralVariables.band)}); + String querySQL = "select distinct [call] from QSLTable where band=?" + modeFilter.sqlSuffix; + Cursor cursor = db.rawQuery(querySQL, modeFilter.withArgs(meter)); ArrayList callsigns = new ArrayList<>(); try { while (cursor.moveToNext()) { @@ -2363,9 +2370,8 @@ public static void get(SQLiteDatabase db) { } GeneralVariables.QSL_Callsign_list = callsigns; - querySQL = "select distinct [call] from QSLTable where band<>?"; - cursor = db.rawQuery(querySQL, new String[]{ - BaseRigOperation.getMeterFromFreq(GeneralVariables.band)}); + querySQL = "select distinct [call] from QSLTable where band<>?" + modeFilter.sqlSuffix; + cursor = db.rawQuery(querySQL, modeFilter.withArgs(meter)); ArrayList other_callsigns = new ArrayList<>(); try { @@ -2386,8 +2392,8 @@ public static void get(SQLiteDatabase db) { // string >= yesterday comparison selects the last two UTC days. long nowUtc = com.k1af.ft8af.timer.UtcTimer.getSystemTime(); String yesterday = com.k1af.ft8af.timer.UtcTimer.getYYYYMMDD(nowUtc - 86400000L); - querySQL = "select distinct [call] from QSLTable where qso_date>=?"; - cursor = db.rawQuery(querySQL, new String[]{yesterday}); + querySQL = "select distinct [call] from QSLTable where qso_date>=?" + modeFilter.sqlSuffix; + cursor = db.rawQuery(querySQL, modeFilter.withArgs(yesterday)); java.util.HashSet today_callsigns = new java.util.HashSet<>(); try { while (cursor.moveToNext()) { @@ -3080,6 +3086,9 @@ protected Void doInBackground(Void... voids) { if (name.equalsIgnoreCase("workedStationList")) { GeneralVariables.addWorkedStationList(result); } + if (name.equalsIgnoreCase("workedSameMode")) {//Restrict worked scopes to the current mode + GeneralVariables.workedSameMode = result.equals("1"); + } if (name.equalsIgnoreCase("distanceInMiles")) { GeneralVariables.distanceInMiles = !result.equals("0"); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/WorkedModeFilter.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/WorkedModeFilter.java new file mode 100644 index 000000000..1f3b49421 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/WorkedModeFilter.java @@ -0,0 +1,64 @@ +package com.k1af.ft8af.database; + +import java.util.Locale; + +/** + * "Same mode only" refinement for the worked-station scopes. + * + *

The worked-before handling (Settings → Decode Highlights) lets the operator + * pick which stations count as worked — on this band, worked before anywhere, + * worked today, or from a list. This adds the orthogonal "and mode" axis the + * feature request asked for ("worked before/today on this band and mode"): + * when {@link com.k1af.ft8af.GeneralVariables#workedSameMode} is on, the worked + * lists loaded by {@link DatabaseOpr.GetAllQSLCallsign} are additionally + * restricted to QSOs made on the current operating mode, so a station worked + * only on a different mode (e.g. FT4 while you're on FT8) still counts as new. + * + *

Kept as a plain, side-effect-free helper (no DB/Android types) so the + * predicate is unit-testable without Robolectric. + */ +public final class WorkedModeFilter { + + /** SQL fragment appended after an existing {@code WHERE} clause (empty when disabled). */ + public final String sqlSuffix; + /** Positional bind arguments for {@link #sqlSuffix}, in order (never null). */ + public final String[] args; + + private WorkedModeFilter(String sqlSuffix, String[] args) { + this.sqlSuffix = sqlSuffix; + this.args = args; + } + + /** + * Build the mode predicate. + * + * @param sameMode whether the "same mode only" refinement is enabled + * @param mode current operating-mode name ("FT8"/"FT4"…); a null/blank + * mode disables the predicate even when {@code sameMode} is on + */ + public static WorkedModeFilter build(boolean sameMode, String mode) { + if (!sameMode || mode == null || mode.trim().isEmpty()) { + return new WorkedModeFilter("", new String[0]); + } + // Case-insensitive compare so a hand-imported "ft8" still matches "FT8". + return new WorkedModeFilter( + " and upper(mode) = ?", + new String[]{mode.trim().toUpperCase(Locale.US)}); + } + + /** + * Combine a base set of query arguments with this filter's extra args. + * + * @param baseArgs the arguments already required by the query (e.g. band) + * @return {@code baseArgs} followed by {@link #args} (the input array when no extras) + */ + public String[] withArgs(String... baseArgs) { + if (args.length == 0) { + return baseArgs; + } + String[] combined = new String[baseArgs.length + args.length]; + System.arraycopy(baseArgs, 0, combined, 0, baseArgs.length); + System.arraycopy(args, 0, combined, baseArgs.length, args.length); + return combined; + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt index 198d00aec..c7f1788ef 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt @@ -32,6 +32,7 @@ fun DecodeFilterSettings( var highlightWorked by remember { mutableStateOf(GeneralVariables.highlightWorked) } var workedStationMode by remember { mutableStateOf(GeneralVariables.workedStationMode) } var workedStationScope by remember { mutableStateOf(GeneralVariables.workedStationScope) } + var workedSameMode by remember { mutableStateOf(GeneralVariables.workedSameMode) } var workedStationList by remember { mutableStateOf(GeneralVariables.getWorkedStationList()) } var highlightPota by remember { mutableStateOf(GeneralVariables.highlightPota) } var distanceInMiles by remember { mutableStateOf(GeneralVariables.distanceInMiles) } @@ -297,6 +298,25 @@ fun DecodeFilterSettings( showChevron = true, onClick = { showWorkedScopePicker = true }, ) + // "and mode" refinement — meaningless for the user list, so + // only offer it for the band/before/today scopes. + if (workedStationScope != workedScopeFromList) { + SectionDivider() + SettingsRow( + label = stringResource(R.string.settings_worked_same_mode), + description = stringResource(R.string.settings_worked_same_mode_desc), + toggle = workedSameMode, + onToggleChange = { checked -> + workedSameMode = checked + GeneralVariables.workedSameMode = checked + mainViewModel.databaseOpr.writeConfig( + "workedSameMode", if (checked) "1" else "0", null, + ) + // Reload the worked lists so the new filter applies now. + mainViewModel.databaseOpr.getAllQSLCallsigns() + }, + ) + } if (workedStationScope == workedScopeFromList) { SectionDivider() SettingsRow( diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 95bdb1f70..0d4f87f17 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -564,6 +564,8 @@ In worked list Worked List Callsigns to treat as worked (comma-separated) + Same mode only + Only count QSOs made on the current mode Distance in Miles Show distances in miles instead of kilometers diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/GetAllQSLCallsignModeTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/GetAllQSLCallsignModeTest.java new file mode 100644 index 000000000..8cb001ee2 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/GetAllQSLCallsignModeTest.java @@ -0,0 +1,87 @@ +package com.k1af.ft8af.database; + +import static com.google.common.truth.Truth.assertThat; + +import android.database.sqlite.SQLiteDatabase; + +import com.k1af.ft8af.FT8Common; +import com.k1af.ft8af.GeneralVariables; +import com.k1af.ft8af.timer.UtcTimer; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** + * End-to-end coverage for the "same mode only" refinement wired into + * {@link DatabaseOpr.GetAllQSLCallsign}: with an in-memory QSLTable we verify + * that {@link GeneralVariables#workedSameMode} restricts the WORKED (current + * band), NEW_BAND (other band) and TODAY worked lists to the current operating + * mode. Robolectric because it drives real SQLite. + */ +@RunWith(RobolectricTestRunner.class) +public class GetAllQSLCallsignModeTest { + + private SQLiteDatabase db; + + @Before + public void setUp() { + db = SQLiteDatabase.create(null); + db.execSQL("CREATE TABLE QSLTable (id INTEGER PRIMARY KEY, [call] TEXT, band TEXT, " + + "mode TEXT, qso_date TEXT, gridsquare TEXT, sig TEXT, sig_info TEXT)"); + + // Operating on 20m / FT8. + GeneralVariables.band = 14074000L; + GeneralVariables.operatingMode = FT8Common.FT8_MODE; + GeneralVariables.workedSameMode = false; + + String today = UtcTimer.getYYYYMMDD(UtcTimer.getSystemTime()); + + insert("ONBAND_FT8", "20m", "FT8", "20200101"); + insert("ONBAND_FT4", "20m", "FT4", "20200101"); + insert("OTHER_FT8", "40m", "FT8", "20200101"); + insert("OTHER_FT4", "40m", "FT4", "20200101"); + insert("TODAY_FT8", "15m", "FT8", today); + insert("TODAY_FT4", "15m", "FT4", today); + } + + @After + public void tearDown() { + if (db != null) { + db.close(); + } + GeneralVariables.workedSameMode = false; + } + + private void insert(String call, String band, String mode, String date) { + db.execSQL("INSERT INTO QSLTable ([call], band, mode, qso_date, gridsquare) VALUES " + + "(?, ?, ?, ?, ?)", new Object[]{call, band, mode, date, "EM48"}); + } + + @Test + public void sameModeOffLoadsAllModes() { + DatabaseOpr.GetAllQSLCallsign.get(db); + + assertThat(GeneralVariables.QSL_Callsign_list) + .containsExactly("ONBAND_FT8", "ONBAND_FT4"); + assertThat(GeneralVariables.QSL_Callsign_list_other_band) + .containsExactly("OTHER_FT8", "OTHER_FT4", "TODAY_FT8", "TODAY_FT4"); + assertThat(GeneralVariables.QSL_Callsign_list_today) + .containsExactly("TODAY_FT8", "TODAY_FT4"); + } + + @Test + public void sameModeOnDropsOtherModeAcrossAllLists() { + GeneralVariables.workedSameMode = true; + + DatabaseOpr.GetAllQSLCallsign.get(db); + + // Only FT8 contacts survive while operating FT8. + assertThat(GeneralVariables.QSL_Callsign_list).containsExactly("ONBAND_FT8"); + assertThat(GeneralVariables.QSL_Callsign_list_other_band) + .containsExactly("OTHER_FT8", "TODAY_FT8"); + assertThat(GeneralVariables.QSL_Callsign_list_today).containsExactly("TODAY_FT8"); + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/WorkedModeFilterTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/WorkedModeFilterTest.java new file mode 100644 index 000000000..dac00fe4b --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/WorkedModeFilterTest.java @@ -0,0 +1,45 @@ +package com.k1af.ft8af.database; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Pure-logic tests for {@link WorkedModeFilter} — the "same mode only" + * refinement behind the worked-station scopes. No Android/DB types, so no + * Robolectric runner is needed. + */ +public class WorkedModeFilterTest { + + @Test + public void disabledAddsNoPredicate() { + WorkedModeFilter f = WorkedModeFilter.build(false, "FT8"); + assertThat(f.sqlSuffix).isEmpty(); + assertThat(f.args).isEmpty(); + } + + @Test + public void enabledAddsUpperCaseModePredicate() { + WorkedModeFilter f = WorkedModeFilter.build(true, "ft4"); + assertThat(f.sqlSuffix).isEqualTo(" and upper(mode) = ?"); + assertThat(f.args).asList().containsExactly("FT4"); + } + + @Test + public void enabledWithBlankOrNullModeIsInert() { + assertThat(WorkedModeFilter.build(true, null).sqlSuffix).isEmpty(); + assertThat(WorkedModeFilter.build(true, " ").args).isEmpty(); + } + + @Test + public void withArgsAppendsExtras() { + WorkedModeFilter f = WorkedModeFilter.build(true, "FT8"); + assertThat(f.withArgs("40m")).asList().containsExactly("40m", "FT8").inOrder(); + } + + @Test + public void withArgsReturnsBaseUnchangedWhenDisabled() { + WorkedModeFilter f = WorkedModeFilter.build(false, "FT8"); + assertThat(f.withArgs("40m")).asList().containsExactly("40m"); + } +} From b5da60345174661e0548c353d441f90319e73773 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 09:53:53 -0500 Subject: [PATCH 029/113] Review fix: close the asset stream with try-with-resources Streams.readAllBytes can throw part-way through a read, and the manual close() after it was skipped on that path, leaking the AssetInputStream. Same fix in both help/clear-cache dialogs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/ui/ClearCacheDataDialog.java | 11 +++++------ .../src/main/java/com/k1af/ft8af/ui/HelpDialog.java | 11 +++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ClearCacheDataDialog.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ClearCacheDataDialog.java index 70a5a584e..81981dce9 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ClearCacheDataDialog.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ClearCacheDataDialog.java @@ -218,12 +218,11 @@ public void show() { public String getTextFromAssets(String fileName) { AssetManager assetManager = context.getAssets(); - try { - InputStream inputStream = assetManager.open(fileName); - byte[] bytes = Streams.readAllBytes(inputStream); - inputStream.close(); - - return new String(bytes); + // try-with-resources: readAllBytes can throw part-way through a read, and a + // manual close() after it would be skipped on that path, leaking the + // AssetInputStream. + try (InputStream inputStream = assetManager.open(fileName)) { + return new String(Streams.readAllBytes(inputStream)); } catch (IOException e) { e.printStackTrace(); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/HelpDialog.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/HelpDialog.java index c9a010920..f876cb9aa 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/HelpDialog.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/HelpDialog.java @@ -160,12 +160,11 @@ public void show() { public String getTextFromAssets(String fileName) { AssetManager assetManager = context.getAssets(); - try { - InputStream inputStream = assetManager.open(fileName); - byte[] bytes = Streams.readAllBytes(inputStream); - inputStream.close(); - - return new String(bytes); + // try-with-resources: readAllBytes can throw part-way through a read, and a + // manual close() after it would be skipped on that path, leaking the + // AssetInputStream. + try (InputStream inputStream = assetManager.open(fileName)) { + return new String(Streams.readAllBytes(inputStream)); } catch (IOException e) { e.printStackTrace(); From 6e29e92239e8df6c65a19e8ccd2df75853132153 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 09:55:25 -0500 Subject: [PATCH 030/113] Review fixes: clarify checkHead's -1 contract and the sync-byte wording - Javadoc: -1 also covers "sync run present but the length byte hasn't arrived yet" (a read ending inside the run), not just "no sync". - Test comment: the scattered 0xA5s are sync bytes; the old bug was returning an index into the sync run so the caller read a sync byte as the length byte. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/java/com/k1af/ft8af/rigs/GuoHeQ900Rig.java | 8 ++++++-- .../test/java/com/k1af/ft8af/rigs/GuoHeCheckHeadTest.java | 5 +++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/GuoHeQ900Rig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/GuoHeQ900Rig.java index af4d33159..2aede6eb1 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/GuoHeQ900Rig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/GuoHeQ900Rig.java @@ -85,8 +85,12 @@ public void setFreqToRig() { /** * Locate the frame length byte, i.e. the first byte after the mandatory * FOUR CONSECUTIVE 0xA5 sync bytes that begin every GuoHe frame (see - * {@link GuoHeRigConstant}). Returns the index of that length byte, or -1 if - * no sync run is present. + * {@link GuoHeRigConstant}). Returns the index of that length byte, or -1 + * when this buffer does not contain one — either because no run of four + * 0xA5 bytes is present at all, or because the run is present but the byte + * that follows it has not arrived yet (a read that ends inside the sync + * run). Both cases mean "no complete frame header here"; the caller simply + * waits for more data. * *

The 0xA5 bytes must be consecutive: the payload of a status frame * carries two big-endian VFO frequencies that are frequently 0xA5, and when diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/GuoHeCheckHeadTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/GuoHeCheckHeadTest.java index a9b33ca27..b077a21ef 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/GuoHeCheckHeadTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/GuoHeCheckHeadTest.java @@ -34,8 +34,9 @@ public void wellFormedFrame_returnsLengthByteIndex() { @Test public void nonConsecutiveA5_isNotMistakenForSync() { // Three scattered 0xA5 payload bytes then a *real* 4-byte sync run. - // The old counter reached 4 mid-way and returned an index into a run of - // 0xA5 length bytes; the fixed counter resets on every non-0xA5 byte. + // The old counter reached 4 on the scattered bytes and returned an index + // pointing *into* the sync run, so the caller read a 0xA5 sync byte as + // the length byte; the fixed counter resets on every non-0xA5 byte. byte[] data = { (byte) 0xA5, (byte) 0x01, (byte) 0xA5, (byte) 0x02, (byte) 0xA5, (byte) 0xA5, (byte) 0xA5, (byte) 0xA5, (byte) 0xA5, // 4-byte sync From d6a1bd77a0f0b812695430edf1a0dded12f372ee Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 20:08:16 +0000 Subject: [PATCH 031/113] Fix ArrayIndexOutOfBoundsException in FT8Package.getStdCall for all-slash callsigns getStdCall("/") (or any all-slash string) crashes: String.split("/") strips trailing empty tokens, so an all-slash input produces a zero-length array. The method skips both loops and then evaluates callsigns[0], throwing ArrayIndexOutOfBoundsException. This is the same defect PR #509 fixed in GeneralVariables.getShortCallsign; that guard was never mirrored here. getStdCall runs during TX packing (generatePack77_i1) when both parties have compound callsigns, so a misconfigured all-slash own-callsign turns every transmit into an exception. Fix: fall back to the input when the split yields no segments, mirroring the getShortCallsign guard. Adds unit coverage for all-slash, trailing-slash, and leading-slash callsigns to FT8PackageTest. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/ft8signal/FT8Package.java | 6 +++++ .../k1af/ft8af/ft8signal/FT8PackageTest.java | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8signal/FT8Package.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8signal/FT8Package.java index 4dc4dc346..18c21632c 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8signal/FT8Package.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8signal/FT8Package.java @@ -109,6 +109,12 @@ public static byte[] generatePack77_i4(Ft8Message message) { public static String getStdCall(String compoundCallsign) { if (!compoundCallsign.contains("/")) return compoundCallsign; String[] callsigns = compoundCallsign.split("/"); + // An all-slash string ("/", "//", ...) splits to a zero-length array + // because Java strips trailing empty tokens; there is no segment to + // extract, so fall back to the input rather than indexing callsigns[0] + // below and throwing ArrayIndexOutOfBoundsException. Mirrors the same + // guard in GeneralVariables.getShortCallsign. + if (callsigns.length == 0) return compoundCallsign; for (String callsign : callsigns) {// extract standard callsign using regex // FT8 definition: a standard amateur callsign consists of a one or two character prefix (at least one must be a letter), followed by a decimal digit and up to three letter suffix. if (callsign.matches("[A-Z0-9]?[A-Z0-9][0-9][A-Z][A-Z0-9]?[A-Z]?")) { diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ft8signal/FT8PackageTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ft8signal/FT8PackageTest.java index 9432ab05d..277fd02d9 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/ft8signal/FT8PackageTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ft8signal/FT8PackageTest.java @@ -38,4 +38,26 @@ public void noStandardSegment_fallsBackToLongest() { // Neither side matches the standard shape -> return the longest segment. assertThat(FT8Package.getStdCall("PY1/ZZ")).isEqualTo("PY1"); } + + @Test + public void allSlashCallsign_returnsInputInsteadOfCrashing() { + // A string of only slashes splits to a zero-length array (Java strips + // trailing empty tokens), so there is no segment to fall back to. + // Must not throw ArrayIndexOutOfBoundsException; return the input as-is. + assertThat(FT8Package.getStdCall("/")).isEqualTo("/"); + assertThat(FT8Package.getStdCall("//")).isEqualTo("//"); + assertThat(FT8Package.getStdCall("///")).isEqualTo("///"); + } + + @Test + public void trailingSlash_stillReturnsSegment() { + // Trailing slash is dropped by split but a real segment remains. + assertThat(FT8Package.getStdCall("W1AW/")).isEqualTo("W1AW"); + } + + @Test + public void leadingSlash_stillReturnsSegment() { + // Leading empty token is preserved by split; the callsign segment wins. + assertThat(FT8Package.getStdCall("/K1ABC")).isEqualTo("K1ABC"); + } } From b8399004927d39ec7563f5c1a8791c170beeb1dd Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 21:09:19 +0000 Subject: [PATCH 032/113] Fix Xiegu X6100 supply-voltage meter under-reading below 5V getMeter_volt's low segment (raw value 0..75) divided the raw meter value by 25 instead of 15. The class-documented calibration is 0000=0V, 0075=5V, 0241=16V, so the 0..75 segment must be linear from 0V to 5V (slope 5/75 = 1/15). Dividing by 25 mapped that segment to 0..3V, under-reporting the supply voltage across the whole low range and leaving a discontinuity with the correct upper segment at the knee (value 75 read 3.0V on the low branch vs 5.0V on the upper branch). Only the low branch changes; the upper segment already matched the calibration and is untouched. This only affected the displayed supply voltage on the Xiegu X6100 network meter panel (a battery-powered radio, where an accurate low-voltage reading matters), not TX behavior or protocol. Adds pure-JVM X6100MetersVoltTest covering both calibration knees, the segment continuity at value 75, and monotonicity; the low-segment cases fail against the old /25 divisor. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/x6100/X6100Meters.java | 6 +- .../k1af/ft8af/x6100/X6100MetersVoltTest.java | 71 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/x6100/X6100MetersVoltTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/x6100/X6100Meters.java b/ft8af/app/src/main/java/com/k1af/ft8af/x6100/X6100Meters.java index 282c93725..e859401fc 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/x6100/X6100Meters.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/x6100/X6100Meters.java @@ -68,8 +68,12 @@ public static float getMeter_dBm(float value) { * @return voltage */ public static float getMeter_volt(float value) { + // Calibration (see class header): 0000=0V, 0075=5V, 0241=16V. + // Low segment 0..75 is linear 0V..5V (slope 5/75 = 1/15); the previous + // 1/25 divisor under-read the supply (75 -> 3V, not 5V) and left a + // discontinuity with the upper segment at value == 75. if (value <= 75) { - return value / 25f; + return value / 15f; } else { return (value - 75f) * 11 / 166f + 5; } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/x6100/X6100MetersVoltTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/x6100/X6100MetersVoltTest.java new file mode 100644 index 000000000..5f93028ca --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/x6100/X6100MetersVoltTest.java @@ -0,0 +1,71 @@ +package com.k1af.ft8af.x6100; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Pure-JVM coverage for {@link X6100Meters#getMeter_volt(float)} — the Xiegu + * X6100 supply-voltage meter conversion. + * + *

The device sends a raw 0..255 meter value; the class header documents the + * calibration as {@code 0000=0V, 0075=5V, 0241=16V}. The low segment (0..75) + * must therefore be linear from 0V to 5V (slope 5/75 = 1/15). The previous + * implementation divided by 25 instead of 15, so a raw value of 75 read 3.0V + * instead of 5.0V and every reading below the 5V knee under-reported the supply + * — and the two segments no longer met at the knee. + * + *

No Robolectric runner: {@code getMeter_volt} is pure arithmetic and never + * touches Android types. + */ +public class X6100MetersVoltTest { + + private static final float TOL = 0.02f; + + @Test + public void zero_isZeroVolts() { + assertThat(X6100Meters.getMeter_volt(0f)).isWithin(TOL).of(0.0f); + } + + @Test + public void knee_at75_is5Volts() { + // Regression: the old 1/25 divisor returned 3.0V here. + assertThat(X6100Meters.getMeter_volt(75f)).isWithin(TOL).of(5.0f); + } + + @Test + public void lowSegment_midpoint_isHalfOf5Volts() { + // 37.5 -> 2.5V on the 0..75 -> 0..5V ramp (old code gave 1.5V). + assertThat(X6100Meters.getMeter_volt(37.5f)).isWithin(TOL).of(2.5f); + } + + @Test + public void lowSegment_isContinuousWithUpperSegmentAtKnee() { + // Both branches must agree at the knee (value == 75) so the reading + // does not jump. The old low branch (3.0V) broke this. + float low = X6100Meters.getMeter_volt(75f); + float high = X6100Meters.getMeter_volt(75.0001f); + assertThat(low).isWithin(0.01f).of(high); + } + + @Test + public void upperCalibrationPoint_at241_is16Volts() { + assertThat(X6100Meters.getMeter_volt(241f)).isWithin(TOL).of(16.0f); + } + + @Test + public void upperSegment_midpoint() { + // Halfway between the 75 (5V) and 241 (16V) points -> 10.5V. + assertThat(X6100Meters.getMeter_volt(158f)).isWithin(TOL).of(10.5f); + } + + @Test + public void isMonotonicallyIncreasing() { + float prev = X6100Meters.getMeter_volt(0f); + for (int v = 1; v <= 255; v++) { + float cur = X6100Meters.getMeter_volt(v); + assertThat(cur).isGreaterThan(prev); + prev = cur; + } + } +} From 838e670f3d41f68a6a8d2485f7f9e7deeab724fc Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 17:36:44 -0500 Subject: [PATCH 033/113] Review fixes: correct the filter/rate/alignment wording - The stopband claim was directionally wrong. The cutoff is 0.45/max(L,M) of the interpolated rate (~5.4 kHz for a 12 kHz source): the passband clears FT8's ~3 kHz top, and it's the 10.5/13.5 kHz images that sit in the stopband. - Group-delay compensation is a whole number of output samples, so for a non-integer ratio alignment holds to within one output sample, not exactly. - Stop implying 48 kHz is the only device rate in UsbAudioDevice.resample and the test header; 44.1 kHz (147/40) takes the same path and is covered. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/wave/TxUpsampler.java | 18 ++++++++++++------ .../com/k1af/ft8af/wave/UsbAudioDevice.java | 3 ++- .../com/k1af/ft8af/wave/TxUpsamplerTest.java | 4 +++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/TxUpsampler.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/TxUpsampler.java index 6dc27bad4..64409f753 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/TxUpsampler.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/TxUpsampler.java @@ -5,7 +5,8 @@ * {@link UsbAudioDevice#writeAudio}. * *

Why it exists (distorted TX audio with the Yaesu FT-710): the FT8 waveform is generated at - * 12 kHz and the USB audio device streams at 48 kHz, so writeAudio() must upsample 4x. The old + * 12 kHz and the USB audio device streams at a higher rate — commonly 48 kHz, so writeAudio() + * upsamples 4x, though 44.1 kHz devices (a 147/40 ratio) go through the same path. The old * path did this with naive linear interpolation. A linear interpolator is a convolution with a * triangular kernel, whose frequency response ({@code sinc^2}) only lightly attenuates the * spectral images of the 12 kHz-sampled tone. For a ~1500 Hz FT8 tone the first images land at @@ -16,9 +17,11 @@ * *

The fix reuses the same Blackman-windowed-sinc polyphase kernel already used (and * host-tested) on the capture side ({@link RationalResampler}): interpolate by L, low-pass, - * decimate by M for the exact reduced ratio {@code toRate/fromRate}. The stopband sits well below - * FT8's ~3 kHz top, so the images are rejected by > 40 dB and the transmitted tone is clean. - * Covered by {@code TxUpsamplerTest}. + * decimate by M for the exact reduced ratio {@code toRate/fromRate}. The low-pass cutoff is + * {@code 0.45 / max(L, M)} of the interpolated rate — about 5.4 kHz for a 12 kHz source — so the + * passband clears FT8's ~3 kHz top with room to spare while the images at 10.5/13.5 kHz sit deep + * in the stopband and are rejected by > 40 dB, leaving the transmitted tone clean. Covered by + * {@code TxUpsamplerTest}. */ public final class TxUpsampler { @@ -29,8 +32,11 @@ private TxUpsampler() {} * band-limited polyphase filter. Returns a buffer of exactly {@code input.length * L / M} * samples, where {@code L/M} is the reduced {@code toRate/fromRate} ratio, group-delay * compensated so output sample 0 lines up with input sample 0 (the FT8 leading Costas sync - * array must not be shifted or clipped). Returns the input array unchanged when the rates - * match or either rate is non-positive. + * array must not be shifted or clipped). The compensation is a whole number of output + * samples, so for a non-integer ratio (e.g. 12 kHz -> 44.1 kHz, L/M = 147/40) the residual + * FIR group delay is fractional and the alignment holds to within one output sample rather + * than exactly — far below what the decoder's sync search cares about. Returns the input + * array unchanged when the rates match or either rate is non-positive. */ public static float[] resample(float[] input, int fromRate, int toRate) { if (fromRate <= 0 || toRate <= 0 || fromRate == toRate || input.length == 0) { diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java index 357e127b7..b171195ec 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/UsbAudioDevice.java @@ -917,7 +917,8 @@ public boolean writeAudio(float[] audioData, int sourceSampleRate) { } /** - * Band-limited TX resampler (12 kHz FT8 generator rate -> the device's 48 kHz output rate). + * Band-limited TX resampler (12 kHz FT8 generator rate -> whatever rate the USB device + * streams at — commonly 48 kHz, but 44.1 kHz and other rates take the same path). * *

Delegates to {@link TxUpsampler}, which uses the same polyphase windowed-sinc kernel as * the capture path. The previous naive linear interpolator left the 12 kHz sampling images diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/wave/TxUpsamplerTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/wave/TxUpsamplerTest.java index 52538ac97..bfd0ddd8d 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/wave/TxUpsamplerTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/TxUpsamplerTest.java @@ -7,7 +7,9 @@ /** * Guards the band-limited TX upsampler used by the USB-direct (libusb) output path in * {@link UsbAudioDevice#writeAudio}. The FT8 waveform is generated at 12 kHz and the USB audio - * device streams at 48 kHz, so writeAudio() must upsample 4x. + * device streams at a higher rate — commonly 48 kHz (a 4x upsample), which is what most of the + * cases below use; {@code handlesNonIntegerRatio44100} covers a 44.1 kHz device, where the ratio + * reduces to 147/40. * *

The bug this covers (distorted TX audio with the Yaesu FT-710): the old path used naive * linear interpolation, whose triangular kernel leaves the spectral images of the 12 kHz-sampled From f0914072f8367685b6dad95e7dca14374c0a6461 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 17:41:22 -0500 Subject: [PATCH 034/113] Review fixes: reload worked lists on a mode switch that doesn't retune setOperatingMode reloaded the worked lists only inside the retune branch, but with "same mode only" on the lists are keyed by operating mode as well as band. A mode switch that leaves the dial alone (no band entry for the new mode, or already on the target frequency) left the previous mode's stations highlighted/hidden until an unrelated reload ran. The reload condition now lives in WorkedModeFilter.reloadNeededOnModeChange with three cases pinned in WorkedModeFilterTest. Also restore every global GetAllQSLCallsignModeTest touches (the three worked lists plus band/mode) in tearDown, so it can't leak state into other tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/MainViewModel.java | 14 +++++++++- .../k1af/ft8af/database/WorkedModeFilter.java | 17 ++++++++++++ .../database/GetAllQSLCallsignModeTest.java | 12 +++++++++ .../ft8af/database/WorkedModeFilterTest.java | 26 +++++++++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) 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 2c8dd49de..4d79a470d 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -68,6 +68,7 @@ import com.k1af.ft8af.database.OnAfterQueryFollowCallsigns; import com.k1af.ft8af.database.OperationBand; import com.k1af.ft8af.database.RigNameList; +import com.k1af.ft8af.database.WorkedModeFilter; import com.k1af.ft8af.flex.FlexRadio; import com.k1af.ft8af.flex.RadioTcpClient; import com.k1af.ft8af.ft8listener.FT8SignalListener; @@ -1533,15 +1534,26 @@ public boolean setOperatingMode(int modeId) { // Retune within the SAME band to the new mode's dial (see safety note above). String waveLength = BaseRigOperation.getMeterFromFreq(GeneralVariables.band); long newFreq = OperationBand.getModeBandFreq(waveLength, normId); + boolean retuned = false; if (newFreq > 0 && newFreq != GeneralVariables.band) { GeneralVariables.band = newFreq; GeneralVariables.bandListIndex = OperationBand.getIndexByFreq(newFreq); databaseOpr.writeConfig("bandFreq", String.valueOf(newFreq), null); - databaseOpr.getAllQSLCallsigns(); + retuned = true; GeneralVariables.mutableBandChange.postValue(GeneralVariables.bandListIndex); setOperationBand();//push the new dial to the rig over CAT (no-op if not connected) } + // Reload the worked lists when this switch invalidated them. A retune always + // does (they are built per band), but with "same mode only" on they are also + // built per operating mode — so a mode switch that does NOT move the dial + // (no band entry for the new mode, or the dial is already on target) would + // otherwise leave the previous mode's worked stations highlighted/hidden + // until some unrelated reload happened to run. See WorkedModeFilter. + if (WorkedModeFilter.reloadNeededOnModeChange(retuned, GeneralVariables.workedSameMode)) { + databaseOpr.getAllQSLCallsigns(); + } + // Mode changed (and possibly retuned within the band) — optionally wipe the // now-stale decodes + reset the TX target so the screen reflects the new mode. if (GeneralVariables.clearOnBandModeChange) { diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/WorkedModeFilter.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/WorkedModeFilter.java index 1f3b49421..daee60b96 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/WorkedModeFilter.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/WorkedModeFilter.java @@ -46,6 +46,23 @@ public static WorkedModeFilter build(boolean sameMode, String mode) { new String[]{mode.trim().toUpperCase(Locale.US)}); } + /** + * Whether an operating-mode switch has invalidated the cached worked lists. + * + *

{@link DatabaseOpr.GetAllQSLCallsign} builds those lists per band and — when the + * "same mode only" refinement is on — per operating mode. {@code MainViewModel.setOperatingMode} + * historically reloaded them only when the switch also retuned the dial, which is not + * guaranteed: the new mode may have no band entry, or the dial may already sit on the target + * frequency. In that case the lists kept the previous mode's stations and the highlighting + * (or hiding) stayed wrong until an unrelated reload ran. + * + * @param retuned whether the mode switch also moved the dial (band-keyed lists are stale) + * @param sameMode whether the "same mode only" refinement is on (mode-keyed lists are stale) + */ + public static boolean reloadNeededOnModeChange(boolean retuned, boolean sameMode) { + return retuned || sameMode; + } + /** * Combine a base set of query arguments with this filter's extra args. * diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/GetAllQSLCallsignModeTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/GetAllQSLCallsignModeTest.java index 8cb001ee2..2bbde31d1 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/database/GetAllQSLCallsignModeTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/GetAllQSLCallsignModeTest.java @@ -25,9 +25,13 @@ public class GetAllQSLCallsignModeTest { private SQLiteDatabase db; + private long savedBand; + private int savedMode; @Before public void setUp() { + savedBand = GeneralVariables.band; + savedMode = GeneralVariables.operatingMode; db = SQLiteDatabase.create(null); db.execSQL("CREATE TABLE QSLTable (id INTEGER PRIMARY KEY, [call] TEXT, band TEXT, " + "mode TEXT, qso_date TEXT, gridsquare TEXT, sig TEXT, sig_info TEXT)"); @@ -52,7 +56,15 @@ public void tearDown() { if (db != null) { db.close(); } + // GetAllQSLCallsign.get() populates process-global lists, and band/mode/toggle are + // global too. Restore every one of them so this class is self-contained and can't + // leak worked stations (or an FT8-only filter) into whatever test runs next. GeneralVariables.workedSameMode = false; + GeneralVariables.QSL_Callsign_list.clear(); + GeneralVariables.QSL_Callsign_list_other_band.clear(); + GeneralVariables.QSL_Callsign_list_today.clear(); + GeneralVariables.band = savedBand; + GeneralVariables.operatingMode = savedMode; } private void insert(String call, String band, String mode, String date) { diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/WorkedModeFilterTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/WorkedModeFilterTest.java index dac00fe4b..8c590acb4 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/database/WorkedModeFilterTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/WorkedModeFilterTest.java @@ -42,4 +42,30 @@ public void withArgsReturnsBaseUnchangedWhenDisabled() { WorkedModeFilter f = WorkedModeFilter.build(false, "FT8"); assertThat(f.withArgs("40m")).asList().containsExactly("40m"); } + + // ---- reloadNeededOnModeChange ------------------------------------------- + // setOperatingMode used to reload the worked lists only when the switch also + // retuned the dial. With "same mode only" on, the lists are keyed by mode too, + // so a mode switch that leaves the dial where it is (no band entry for the new + // mode, or already on target) must still reload — otherwise the previous mode's + // worked stations stay highlighted/hidden. + + @Test + public void reloadNeeded_whenRetuned_regardlessOfSameMode() { + assertThat(WorkedModeFilter.reloadNeededOnModeChange(true, false)).isTrue(); + assertThat(WorkedModeFilter.reloadNeededOnModeChange(true, true)).isTrue(); + } + + @Test + public void reloadNeeded_whenSameModeOn_evenWithoutARetune() { + // The regression this guards: no dial change + "same mode only" on. + assertThat(WorkedModeFilter.reloadNeededOnModeChange(false, true)).isTrue(); + } + + @Test + public void reloadNotNeeded_whenNeitherDialNorModeFilterApplies() { + // Same-mode off and no retune: the band-keyed lists are still valid, so + // skip the DB work. + assertThat(WorkedModeFilter.reloadNeededOnModeChange(false, false)).isFalse(); + } } From b5d82d9f7d56c2ee4386a865ddeffd30619490b2 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 17:44:41 -0500 Subject: [PATCH 035/113] Review fixes: URL-encode href values, escape the remaining DB columns HTML escaping is the wrong tool inside an href: the browser resolves entities before parsing the URL, so an escaped & becomes a real & and splits the query string (a callsign of "A&pageSize=9999" would inject a parameter). - HtmlContext.urlQueryValue() percent-encodes query-parameter values; applied to all five links (message x2, QSOSWLMSG x2, QSOLogs x2). Link text stays HTML-escaped. - HtmlContext.urlPathSegment() does the same for the /delfollow/ path, encoding space as %20 (not '+') and encoding '/' so a value can't reach a different route. - HtmlContext.tableCellEscaped() escapes straight DB columns; used for the mode/RST/date/time/band/freq cells in both log tables, which imported ADIF can populate with arbitrary text. All three helpers covered in HtmlEscapeTest. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/html/HtmlContext.java | 70 ++++++++++++++++++ .../com/k1af/ft8af/html/LogHttpServer.java | 26 ++++--- .../com/k1af/ft8af/html/HtmlEscapeTest.java | 73 +++++++++++++++++++ 3 files changed, 159 insertions(+), 10 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/html/HtmlContext.java b/ft8af/app/src/main/java/com/k1af/ft8af/html/HtmlContext.java index 75725d2be..ef8e1bb37 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/html/HtmlContext.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/html/HtmlContext.java @@ -109,6 +109,76 @@ public static String htmlEscape(String s) { } + /** + * Encode an untrusted value for use as a URL query-parameter value inside a + * quoted {@code href} attribute. + * + *

{@link #htmlEscape} alone is not enough here. It makes a value safe as markup, but + * the browser resolves entities before parsing the URL, so an escaped {@code &} + * becomes a real {@code &} and splits the query string — a callsign of + * {@code A&pageSize=9999} would silently add a parameter (and {@code =}, {@code #}, + * {@code ?}, and spaces mangle the link in the same way). Percent-encoding first means + * the value can only ever be one parameter's value. The percent-encoded output contains + * no markup-significant characters, but it is still run through {@link #htmlEscape} so + * this is safe by construction wherever it is used. + * + * @param s raw value; {@code null} becomes "" + */ + public static String urlQueryValue(String s) { + if (s == null) { + return ""; + } + try { + // Form encoding: correct for a query-parameter value, where '+' means space. + return htmlEscape(java.net.URLEncoder.encode(s, "UTF-8")); + } catch (java.io.UnsupportedEncodingException e) { + // UTF-8 is guaranteed present on every JVM/Android runtime; if it somehow is + // not, drop the value rather than emit it unencoded. + return ""; + } + } + + /** + * Encode an untrusted value for use as a single URL path segment inside a + * quoted {@code href} attribute (e.g. {@code /delfollow/}). + * + *

Same reasoning as {@link #urlQueryValue}, with one difference: in a path segment + * {@code +} is a literal plus sign rather than a space, so the form encoder's {@code +} + * is rewritten to {@code %20}. A {@code /} in the value is percent-encoded too, so a + * value can never escape its own segment and reach a different route. + * + * @param s raw value; {@code null} becomes "" + */ + public static String urlPathSegment(String s) { + if (s == null) { + return ""; + } + try { + return htmlEscape(java.net.URLEncoder.encode(s, "UTF-8").replace("+", "%20")); + } catch (java.io.UnsupportedEncodingException e) { + return ""; + } + } + + /** + * Table cells for untrusted values: identical to {@link #tableCell} except every value is + * run through {@link #htmlEscape} first. + * + *

{@code tableCell} inserts its arguments raw, which is what the call sites building + * {@code } markup need. Straight DB columns (mode, RST, dates, band, frequency…) + * must not be inserted that way: the log tables are populated from decoded traffic and + * from imported ADIF, so a crafted value in any of those columns would be stored XSS in + * the LAN-reachable web logbook. Use this for anything that comes back out of the + * database as text. + */ + public static StringBuilder tableCellEscaped(StringBuilder sb, String... s) { + for (String c : s) { + sb.append(String.format("%s", htmlEscape(c))); + } + sb.append("\n"); + return sb; + } + public static String DEFAULT_HTML() { return HTML_STRING("" + "\n"); + } + + @Test + public void tableCellEscaped_nullValueBecomesEmptyCell() { + StringBuilder sb = new StringBuilder(); + HtmlContext.tableCellEscaped(sb, (String) null); + assertThat(sb.toString()).isEqualTo("\n"); + } } From 5f02472ebc1781b386b09776d44b06f018369d28 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 18:03:00 -0500 Subject: [PATCH 036/113] Fix Kenwood TS-590/570/2000 SWR/ALC meter detected at wrong byte offset (#638) The Kenwood RM (read-meter) reply is "RM P1 P2;": after the "RM" command id is stripped, data = "mvvvv" where P1 (index 0) selects the meter (1 = SWR, 3 = ALC) and P2 (indices 1..4) is the 4-digit value. The value extractor get590ALCOrSWR already reads substring(1,5), but the type detectors is590MeterSWR/is590MeterALC read data.charAt(2) -- the 2nd digit of the value, not the selector. As a result SWR/ALC were recorded only in the coincidental frames where the value's 3rd character happened to be '1'/'3', so metering was erratic and, worse, the high-SWR safety alert in KenwoodTS590/570/2000Rig.showAlert() was unreliable. Read the selector at index 0, matching the sibling isSWRMeter38/39 helpers and the value layout. Added realistic-frame coverage to CatMeterParserTest (3 cases fail before the fix) and corrected two Yaesu3CommandTest cases that had been written against the buggy offset with invalid ('0') selector frames. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/rigs/Yaesu3Command.java | 8 ++- .../k1af/ft8af/rigs/CatMeterParserTest.java | 50 +++++++++++++++++++ .../k1af/ft8af/rigs/Yaesu3CommandTest.java | 15 +++--- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu3Command.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu3Command.java index 5a491918b..1d7e06383 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu3Command.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu3Command.java @@ -141,11 +141,15 @@ private static int parseMeterValue(String token) { public static boolean is590MeterALC(Yaesu3Command command){ if (command.data.length() < 5) return false; - return command.data.charAt(2) == '3'; + // Kenwood "RM" reply is "mvvvv": meter-type selector at index 0 + // (3 = ALC), 4-digit value at indices 1..4. Reading the selector at + // index 2 (a value digit) mis-detected the meter type -- see get590ALCOrSWR. + return command.data.charAt(0) == '3'; } public static boolean is590MeterSWR(Yaesu3Command command){ if (command.data.length() < 5) return false; - return command.data.charAt(2) == '1'; + // Meter-type selector is at index 0 (1 = SWR); see is590MeterALC. + return command.data.charAt(0) == '1'; } /** diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatMeterParserTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatMeterParserTest.java index 09a5475f7..8233412c9 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatMeterParserTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatMeterParserTest.java @@ -85,6 +85,56 @@ public void get590ALCOrSWR_returnsZeroOnShortData() { assertThat(Yaesu3Command.get590ALCOrSWR(c)).isEqualTo(0); } + // ---- Yaesu3Command.is590MeterSWR / is590MeterALC (Kenwood TS-590/2000/570) ---- + // + // The Kenwood "RM" (read-meter) reply is "RM P1 P2;": after the "RM" command + // id is stripped, data = "mvvvv" where P1 (index 0) is the meter-type selector + // (1 = SWR, 3 = ALC) and P2 (indices 1..4) is the 4-digit value. The value + // extractor get590ALCOrSWR already reads substring(1,5), so the type selector + // is data.charAt(0) -- matching the sibling isSWRMeter38/39 which use charAt(0). + + @Test + public void is590MeterSWR_trueForSwrFrame() { + // "RM10015;" -> data "10015": P1 = '1' (SWR), value "0015". + Yaesu3Command c = new Yaesu3Command("RM", "10015"); + assertThat(Yaesu3Command.is590MeterSWR(c)).isTrue(); + } + + @Test + public void is590MeterALC_trueForAlcFrame() { + // "RM30020;" -> data "30020": P1 = '3' (ALC), value "0020". + Yaesu3Command c = new Yaesu3Command("RM", "30020"); + assertThat(Yaesu3Command.is590MeterALC(c)).isTrue(); + } + + @Test + public void is590MeterSWR_falseForAlcFrame() { + Yaesu3Command c = new Yaesu3Command("RM", "30020"); + assertThat(Yaesu3Command.is590MeterSWR(c)).isFalse(); + } + + @Test + public void is590MeterALC_falseForSwrFrame() { + Yaesu3Command c = new Yaesu3Command("RM", "10015"); + assertThat(Yaesu3Command.is590MeterALC(c)).isFalse(); + } + + @Test + public void is590Meter_selectorNotConfusedWithValueDigits() { + // An SWR frame whose value happens to contain a '3' at index 2 must not + // be misread as ALC (the pre-fix charAt(2) bug classified this as ALC). + Yaesu3Command c = new Yaesu3Command("RM", "10300"); + assertThat(Yaesu3Command.is590MeterSWR(c)).isTrue(); + assertThat(Yaesu3Command.is590MeterALC(c)).isFalse(); + } + + @Test + public void is590Meter_returnsFalseOnShortData() { + Yaesu3Command c = new Yaesu3Command("RM", "10"); + assertThat(Yaesu3Command.is590MeterSWR(c)).isFalse(); + assertThat(Yaesu3Command.is590MeterALC(c)).isFalse(); + } + // ---- ElecraftCommand.getSWRMeter (substring(0,3)) ---- @Test diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu3CommandTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu3CommandTest.java index 4fb5c2698..691fb5169 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu3CommandTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu3CommandTest.java @@ -10,7 +10,8 @@ * * Frames are {@code <2-letter ID>}; frequency replies are FA/FB with an * integer Hz string. Meter replies are RM frames whose first data char selects - * the meter (6=SWR, 4=ALC for 38/39; index 2 = 3 ALC / 1 SWR for the 590). + * the meter (6=SWR, 4=ALC for 38/39; 1=SWR, 3=ALC at index 0 for the 590, with + * the 4-digit value in the following chars). */ public class Yaesu3CommandTest { @@ -98,20 +99,20 @@ public void ft39_shortDataReturnsDefaults() { @Test public void ts590_alcSelectorAndValue() { - // data "01300": index2 == '3' -> ALC; value = substring(1,5) = "1300". - Yaesu3Command alc = Yaesu3Command.getCommand("RM01300"); + // data "30020": index0 == '3' -> ALC; value = substring(1,5) = "0020". + Yaesu3Command alc = Yaesu3Command.getCommand("RM30020"); assertThat(Yaesu3Command.is590MeterALC(alc)).isTrue(); assertThat(Yaesu3Command.is590MeterSWR(alc)).isFalse(); - assertThat(Yaesu3Command.get590ALCOrSWR(alc)).isEqualTo(1300); + assertThat(Yaesu3Command.get590ALCOrSWR(alc)).isEqualTo(20); } @Test public void ts590_swrSelector() { - // data "01100": index2 == '1' -> SWR. - Yaesu3Command swr = Yaesu3Command.getCommand("RM01100"); + // data "10015": index0 == '1' -> SWR; value = substring(1,5) = "0015". + Yaesu3Command swr = Yaesu3Command.getCommand("RM10015"); assertThat(Yaesu3Command.is590MeterSWR(swr)).isTrue(); assertThat(Yaesu3Command.is590MeterALC(swr)).isFalse(); - assertThat(Yaesu3Command.get590ALCOrSWR(swr)).isEqualTo(1100); + assertThat(Yaesu3Command.get590ALCOrSWR(swr)).isEqualTo(15); } @Test From 665636a642be74505925ece19ba071372d054d67 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 21:35:34 -0500 Subject: [PATCH 037/113] Fix off-by-one serial port-index guard that could crash the app (#641) CableSerialPort.prepare() guarded the port lookup with `driver.getPorts().size() < portNum` and then indexed the list with `getPorts().get(portNum)`. Port lists are 0-based, so the only safe indices are 0..size-1: the guard is off by one and lets `portNum == size` through (and an empty port list with the default `portNum == 0`), so `get(portNum)` throws IndexOutOfBoundsException out of prepare() and connect(). connect() is driven from the "CAT-Auto-Reconnect" worker inside a try/finally with no catch, so that exception is uncaught and crashes the app rather than surfacing a "port not found" error. It triggers when a persisted multi-port selection is replayed against a device that now enumerates fewer ports (e.g. a different unit sharing the same vendorId), or when a probed driver reports zero ports. Fix: extract a package-visible pure helper isValidPortIndex(portCount, portNum) that requires 0 <= portNum < portCount, and use it in prepare(). This makes the guard actually cover the index it protects and also rejects a negative portNum. Behavior is unchanged for every valid selection (0..size-1). Added pure-JVM CableSerialPortPortIndexTest covering the boundary/empty/negative cases (the boundary + empty + negative cases fail against the old guard). Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../k1af/ft8af/connector/CableSerialPort.java | 23 +++++++- .../CableSerialPortPortIndexTest.java | 57 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/connector/CableSerialPortPortIndexTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java b/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java index b647d758f..8af5a1da1 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java @@ -110,6 +110,27 @@ private boolean shouldUseFt710WriteOnlyCatMode() { && GeneralVariables.controlMode == ControlMode.CAT; } + /** + * Whether {@code portNum} is a valid 0-based index into a driver port list + * of {@code portCount} ports, i.e. in {@code 0 .. portCount-1}. + * + *

{@link #prepare()} calls {@code driver.getPorts().get(portNum)} right + * after this check. The previous guard was {@code portCount < portNum}, + * which is off by one: it let {@code portNum == portCount} (a persisted + * multi-port selection replayed against a device that now enumerates fewer + * ports, or an empty port list with the default {@code portNum == 0}) pass, + * so {@code get(portNum)} threw {@link IndexOutOfBoundsException} out of + * {@code prepare()} → {@code connect()}. On the CAT auto-reconnect worker + * that exception is uncaught and crashes the app. Requiring + * {@code portNum < portCount} (and non-negative) makes the guard actually + * cover the index it protects. + * + *

Package-visible for testing. + */ + static boolean isValidPortIndex(int portCount, int portNum) { + return portNum >= 0 && portNum < portCount; + } + private boolean prepare() { registerRigSerialPort(context); UsbDevice device = null; @@ -137,7 +158,7 @@ private boolean prepare() { //Try adding the unknown device to the CDC driver driver = new CdcAcmSerialDriver(device); } - if (driver.getPorts().size() < portNum) { + if (!isValidPortIndex(driver.getPorts().size(), portNum)) { Log.e(TAG, "Serial port number does not exist, cannot open."); return false; } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/connector/CableSerialPortPortIndexTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/connector/CableSerialPortPortIndexTest.java new file mode 100644 index 000000000..51f9a8c9d --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/connector/CableSerialPortPortIndexTest.java @@ -0,0 +1,57 @@ +package com.k1af.ft8af.connector; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Pure-JVM coverage for {@link CableSerialPort#isValidPortIndex}, the bounds + * guard that decides whether {@code prepare()} may call + * {@code driver.getPorts().get(portNum)}. + * + *

The old guard tested {@code getPorts().size() < portNum} and then indexed + * with {@code get(portNum)}. Port lists are 0-based, so the only safe indices + * are {@code 0 .. size-1}: the guard was off by one and let {@code portNum == + * size} (and an empty port list with {@code portNum == 0}) through, so + * {@code get(portNum)} threw {@link IndexOutOfBoundsException} out of + * {@code prepare()} → {@code connect()}. On the CAT auto-reconnect worker that + * exception is uncaught and crashes the app. These cases pin the boundary. + */ +public class CableSerialPortPortIndexTest { + + @Test + public void firstPortOfSinglePortDriver_isValid() { + assertThat(CableSerialPort.isValidPortIndex(1, 0)).isTrue(); + } + + @Test + public void lastPortOfMultiPortDriver_isValid() { + assertThat(CableSerialPort.isValidPortIndex(4, 3)).isTrue(); + } + + @Test + public void portNumEqualToCount_isRejected() { + // The off-by-one case: a persisted multi-port selection replayed against + // a device that now enumerates fewer ports. Old guard (size < portNum) + // returned "valid" here, then get(portNum) threw. + assertThat(CableSerialPort.isValidPortIndex(2, 2)).isFalse(); + assertThat(CableSerialPort.isValidPortIndex(1, 1)).isFalse(); + } + + @Test + public void emptyPortList_isRejected() { + // A driver that reports zero ports with the default portNum=0: old guard + // (0 < 0 == false) let it through and get(0) threw on an empty list. + assertThat(CableSerialPort.isValidPortIndex(0, 0)).isFalse(); + } + + @Test + public void portNumBeyondCount_isRejected() { + assertThat(CableSerialPort.isValidPortIndex(2, 5)).isFalse(); + } + + @Test + public void negativePortNum_isRejected() { + assertThat(CableSerialPort.isValidPortIndex(2, -1)).isFalse(); + } +} From bc2651c4baadf05fcc68fd293c8e345ebfdfa36d Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 21:35:59 -0500 Subject: [PATCH 038/113] Guard the ColumnarView spectrum strip against a zero-dimension Compose crash (#608) * Guard ColumnarView size/draw so a zero-dim Compose pass can't crash ColumnarView is the Compose-hosted spectrum strip above the waterfall (WaterfallScreen ColumnarStrip, an AndroidView laid out fillMaxWidth().height(96.dp)). It was missing the two defensive guards its identically hosted sibling WaterfallView has: - onSizeChanged called Bitmap.createBitmap(w, h, ARGB_8888) with no w > 0 && h > 0 check, so a transient zero-dimension Compose measurement pass threw IllegalArgumentException ("width and height must be > 0") on the UI thread (WaterfallView:112 guards exactly this). - onDraw dereferenced _canvas/lastBitMap (assigned only in onSizeChanged) with no null check, so a draw before a successful sizing NPE'd, including right after the zero-dimension guard above would have fired (WaterfallView:213 guards exactly this). Both run on the UI-thread layout/draw traversal with no surrounding try/catch, so either one was a whole-app crash. Added the two guards mirroring WaterfallView; the happy path is byte-identical. Added ColumnarViewSizeGuardTest (Robolectric, native graphics) covering zero-width/height/both sizing, draw-before-sizing, draw-after-zero-dim, and the positive-dimension happy path. Five of six cases fail pre-fix (IllegalArgumentException / NullPointerException). Co-Authored-By: Claude Opus 4.8 (1M context) * Review fixes: reference WaterfallView by method, drop the graphics-mode claim - Line-number references (WaterfallView:112/:213) go stale; name the methods. - The repo configures no Robolectric graphicsMode, so the "native graphics" parenthetical was unsupported. Replaced with what I verified instead: reverting either guard fails 5 of the 6 cases here with the exact IllegalArgumentException / NullPointerException from the field. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/ui/ColumnarView.java | 8 ++ .../ft8af/ui/ColumnarViewSizeGuardTest.java | 92 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/ui/ColumnarViewSizeGuardTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ColumnarView.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ColumnarView.java index 7b1cb4937..8c862151f 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ColumnarView.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ColumnarView.java @@ -155,6 +155,10 @@ public void setWaveData(int[] data) { @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { + // Compose layout can momentarily measure this AndroidView-hosted strip at a + // zero dimension; Bitmap.createBitmap(0, ...) throws IllegalArgumentException on + // the UI thread. Mirror the sibling WaterfallView.onSizeChanged guard. + if (w <= 0 || h <= 0) return; setClickable(true); super.onSizeChanged(w, h, oldw, oldh); @@ -177,6 +181,10 @@ protected void onSizeChanged(int w, int h, int oldw, int oldh) { @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); + // _canvas/lastBitMap are assigned together only in onSizeChanged; a draw before a + // successful sizing (including right after the zero-dimension guard above) would + // otherwise NPE. Mirror the sibling WaterfallView.onDraw guard. + if (lastBitMap == null) return; _canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR); for (int i = 0; i < newData.size(); i++) { _canvas.drawRect(newData.get(i), paint); diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ui/ColumnarViewSizeGuardTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ui/ColumnarViewSizeGuardTest.java new file mode 100644 index 000000000..e8981308f --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ui/ColumnarViewSizeGuardTest.java @@ -0,0 +1,92 @@ +package com.k1af.ft8af.ui; + +import static android.graphics.Bitmap.Config.ARGB_8888; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** + * Guards {@link ColumnarView} — the Compose-hosted spectrum strip above the waterfall + * ({@code WaterfallScreen} {@code ColumnarStrip}, an {@code AndroidView} laid out + * {@code fillMaxWidth().height(96.dp)}) — against the two main-thread crashes its + * identically hosted sibling {@link WaterfallView} already guards but ColumnarView did + * not: + * + *

    + *
  1. {@code onSizeChanged} called {@code Bitmap.createBitmap(w, h, ARGB_8888)} with no + * {@code w > 0 && h > 0} check → {@code IllegalArgumentException} + * ("width and height must be > 0") on a transient zero-dimension Compose + * measurement pass ({@code WaterfallView.onSizeChanged} guards exactly this).
  2. + *
  3. {@code onDraw} dereferenced {@code _canvas}/{@code lastBitMap} (assigned only in + * {@code onSizeChanged}) with no null check → {@code NullPointerException} if a + * draw runs before a successful sizing, including right after guard (1) would have + * fired ({@code WaterfallView.onDraw} guards exactly this).
  4. + *
+ * + *

Both run on the UI-thread layout/draw traversal with no surrounding try/catch, so + * either one is a whole-app crash. Robolectric's {@code Bitmap}/{@code Canvas} enforce the + * same preconditions the device does, so these tests reproduce both crashes for real: + * reverting either guard fails five of the six cases below with the exact + * {@code IllegalArgumentException}/{@code NullPointerException} seen in the field. The + * tests call the {@code protected} {@code onSizeChanged}/{@code onDraw} directly (same + * package). + */ +@RunWith(RobolectricTestRunner.class) +public class ColumnarViewSizeGuardTest { + + private ColumnarView newView() { + Context context = ApplicationProvider.getApplicationContext(); + return new ColumnarView(context); + } + + private static Canvas scratchCanvas() { + return new Canvas(Bitmap.createBitmap(64, 32, ARGB_8888)); + } + + @Test + public void onSizeChanged_zeroWidth_doesNotThrow() { + // Pre-fix: Bitmap.createBitmap(0, 32) throws IllegalArgumentException. + newView().onSizeChanged(0, 32, 0, 0); + } + + @Test + public void onSizeChanged_zeroHeight_doesNotThrow() { + // Pre-fix: Bitmap.createBitmap(64, 0) throws IllegalArgumentException. + newView().onSizeChanged(64, 0, 0, 0); + } + + @Test + public void onSizeChanged_zeroBoth_doesNotThrow() { + newView().onSizeChanged(0, 0, 0, 0); + } + + @Test + public void onDraw_beforeAnySizing_doesNotThrow() { + // Pre-fix: _canvas is null (never sized) -> NPE on _canvas.drawColor(...). + newView().onDraw(scratchCanvas()); + } + + @Test + public void onDraw_afterZeroDimSizing_doesNotThrow() { + // The zero-dim size pass leaves the view unsized; a following draw must not NPE. + ColumnarView view = newView(); + view.onSizeChanged(0, 0, 0, 0); + view.onDraw(scratchCanvas()); + } + + @Test + public void onSizeChanged_thenOnDraw_positiveDims_drawsWithoutThrowing() { + // Happy path is unchanged: a real size builds the bitmap and a draw succeeds. + ColumnarView view = newView(); + view.layout(0, 0, 100, 50); + view.onSizeChanged(100, 50, 0, 0); + view.onDraw(scratchCanvas()); + } +} From b270cb9b785b8ee8a9c1ae25b940430576cbdb9c Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 21:36:17 -0500 Subject: [PATCH 039/113] Collapse decode list to one row per station, update in place, with sort options (#632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Collapse decode list to one row per station with sort options The decode list was append-only, so a station heard across several cycles showed up as many duplicate rows. Coalesce the stream to one row per callsign, keeping the latest decode, and update rows in place as new decodes arrive. - Add pure, unit-tested collapseByStation(messages, sortMode) plus DecodeSortMode (last-heard / callsign / SNR), nextSortMode, showTimeGroupDividers, and autoScrollTargetIndex helpers in a new DecodeCollapse.kt. - Filter first, then collapse, so per-chip counts stay correct. - Key LazyColumn rows on the callsign so Compose reuses the row and animates it in place when a station is re-decoded; the entry animation re-fires on a new utcTime as the "just updated" cue. - Add a top-bar sort control (TIME/CALL/SNR) persisted via GeneralVariables.decodeSortMode / DB key "decodeSortMode", mirroring msgMode / clearDecodesEveryCycle. - Time-group dividers and auto-scroll now only apply in last-heard order (newest first, scroll to top); other sorts leave the viewport alone. Tests: DecodeCollapseTest covers duplicate collapse, each sort mode, stable ties, blank-callsign drop, config round-trip, and the helpers; DecodeScreenTest renders the sort label. Co-Authored-By: Claude Opus 4.8 (1M context) * Review fixes: bound seenKeys, normalize row keys, harden config + tests - decodeSortMode hydration now uses parseConfigInt, so whitespace or a non-numeric config value can't throw during startup. - seenKeys accumulated one entry per station per cycle and never shrank — a slow leak on a screen left open for hours. advanceRowAnimation() retains only the currently visible keys; a station that returns still re-animates. - collapseByStation keyed on a normalized callsign but rows keyed off the raw callsignFrom, so mixed case across cycles (reachable via the 5-arg ctor and the hashed-callsign path) churned the row. Both now route through stationKey(); the decode objects are still not mutated. - collapse_isCaseInsensitiveByCallsign passed vacuously (the 3-arg ctor upper-cases), so it sets callsignFrom directly now. - DecodeScreenTest covers CALLSIGN and the LAST_HEARD content description. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/GeneralVariables.java | 2 + .../com/k1af/ft8af/database/DatabaseOpr.java | 6 + .../ks3ckc/ft8af/ui/decode/DecodeCollapse.kt | 151 ++++++++++ .../ks3ckc/ft8af/ui/decode/DecodeScreen.kt | 133 +++++++-- .../src/main/res/values/strings_compose.xml | 9 + .../ft8af/ui/decode/DecodeCollapseTest.kt | 266 ++++++++++++++++++ .../ft8af/ui/decode/DecodeScreenTest.kt | 39 +++ 7 files changed, 575 insertions(+), 31 deletions(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapse.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapseTest.kt diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 631377fe0..7a0f0725f 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -407,6 +407,8 @@ public static Context getMainContext() { public static boolean clearDecodesEveryCycle = false;//Clear the decode list at the start of each cycle + public static int decodeSortMode = 0;//Collapsed decode list sort: 0=last heard,1=callsign,2=SNR (DecodeSortMode.configValue) + public static boolean clearOnBandModeChange = true;//Clear the decode list + reset TX target to CQ when the band or mode changes (default on) public static boolean swr_switch_on = true;//SWR alarm switch diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index ad278f50b..97aeaf2a6 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2708,6 +2708,12 @@ protected Void doInBackground(Void... voids) { GeneralVariables.clearDecodesEveryCycle = result.equals("1"); } + if (name.equalsIgnoreCase("decodeSortMode")) { + // parseConfigInt, not Integer.parseInt: a whitespace/non-numeric value in + // the config table would otherwise throw during startup hydration. + GeneralVariables.decodeSortMode = parseConfigInt(result, 0); + } + if (name.equalsIgnoreCase("clearOnBandModeChange")) { GeneralVariables.clearOnBandModeChange = result.equals("1"); } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapse.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapse.kt new file mode 100644 index 000000000..d3fd6b084 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapse.kt @@ -0,0 +1,151 @@ +package radio.ks3ckc.ft8af.ui.decode + +import com.k1af.ft8af.Ft8Message + +/** + * How the collapsed (one-row-per-station) decode list is ordered. + * + * The [configValue] is the stable integer persisted via + * `GeneralVariables.decodeSortMode` / DB key `"decodeSortMode"` (same pattern as + * `msgMode` / `clearDecodesEveryCycle`). Persist by [configValue], never by + * ordinal, so reordering the enum can't silently repoint a saved choice. + */ +internal enum class DecodeSortMode(val configValue: Int) { + /** Most recently decoded station first (default). */ + LAST_HEARD(0), + + /** Alphabetical by callsign. */ + CALLSIGN(1), + + /** Strongest signal (highest SNR) first. */ + SNR(2), + ; + + companion object { + /** Resolve a persisted [configValue] back to a mode, defaulting to [LAST_HEARD]. */ + fun fromConfig(value: Int): DecodeSortMode = + entries.firstOrNull { it.configValue == value } ?: LAST_HEARD + } +} + +/** The mode selected when the operator taps the sort control while on [current]. */ +internal fun nextSortMode(current: DecodeSortMode): DecodeSortMode = when (current) { + DecodeSortMode.LAST_HEARD -> DecodeSortMode.CALLSIGN + DecodeSortMode.CALLSIGN -> DecodeSortMode.SNR + DecodeSortMode.SNR -> DecodeSortMode.LAST_HEARD +} + +/** + * Collapse an append-only decode stream to one row per station, keeping the + * latest decode for each callsign, then order the result per [sortMode]. + * + * "Latest" is the decode with the greatest [Ft8Message.utcTime]; on a tie the + * one that appears later in [messages] wins (it's the newer decode in the raw + * append-order stream). Messages with a null/blank callsign have nothing to + * coalesce on and are dropped — the row UI keys entirely off the callsign. + * + * Sorting is stable: ties keep first-appearance order in [messages], so the + * list doesn't jitter as identical-key decodes stream in. + * + * Callers should collapse **after** filtering so the visible-station count + * agrees with the active chip filter (see `filterMessages`). + */ +internal fun collapseByStation( + messages: List, + sortMode: DecodeSortMode, +): List { + // LinkedHashMap preserves first-appearance order, which is our stable + // tiebreak. Overwrite with the newer decode (>= keeps later-on-tie). + val latest = LinkedHashMap() + for (m in messages) { + val cs = stationKey(m) + if (cs.isNullOrEmpty()) continue + val existing = latest[cs] + if (existing == null || m.utcTime >= existing.utcTime) { + latest[cs] = m + } + } + val collapsed = latest.values.toList() + return when (sortMode) { + DecodeSortMode.LAST_HEARD -> collapsed.sortedByDescending { it.utcTime } + DecodeSortMode.CALLSIGN -> collapsed.sortedBy { it.callsignFrom?.uppercase().orEmpty() } + // Unknown SNR sinks to the bottom rather than pretending to be very weak. + DecodeSortMode.SNR -> collapsed.sortedByDescending { + if (it.hasSnr()) it.snr else Int.MIN_VALUE + } + } +} + +/** + * The station identity a decode collapses onto: [Ft8Message.callsignFrom] trimmed + * and upper-cased, or null when there is nothing to coalesce on. + * + * [collapseByStation] returns the original [Ft8Message] objects, whose + * `callsignFrom` field is *not* rewritten to this normalized form — the decode + * objects are shared with the rest of the app and must not be mutated here. Only + * one constructor of `Ft8Message` upper-cases the callsign; the others (and the + * hashed-callsign resolution path) can leave mixed case or padding in place. Any + * caller that keys UI state off a station must therefore route through this same + * function, or a station arriving as "k1abc" one cycle and "K1ABC" the next would + * collapse to one row while its row key churned — recreating the row and + * defeating the update-in-place behaviour this file exists to provide. + */ +internal fun stationKey(message: Ft8Message): String? = + message.callsignFrom?.trim()?.uppercase() + +/** + * The per-render bookkeeping behind the "row is new or just updated" entry + * animation: [seen] is what to retain for the next render, [new] is what to + * animate now. + */ +internal data class RowAnimationState( + val seen: Set, + val new: Set, +) + +/** + * Advance the entry-animation state for a render whose visible rows are [current]. + * + * Retains exactly [current] rather than accumulating every key ever seen. The + * keys embed a timestamp (see [rowAnimationKey]), so a union would add one entry + * per station per cycle and grow without bound over a long session even though + * the visible list stays small — a slow leak in a screen that is left running for + * hours. Anything absent from [current] can no longer be on screen, so dropping + * it is safe; if that station returns later it animates again, which is the + * intended "just updated" cue. + */ +internal fun advanceRowAnimation( + previousSeen: Set, + current: Set, +): RowAnimationState = RowAnimationState(seen = current, new = current - previousSeen) + +/** + * Entry-animation key for one collapsed row: normalized station plus the decode's + * timestamp, so re-decoding a station in a later cycle re-triggers the highlight. + */ +internal fun rowAnimationKey(message: Ft8Message): String = + "${stationKey(message).orEmpty()}_${message.utcTime}" + +/** + * Whether the per-cycle time-group dividers make sense for [sortMode]. They only + * do in [DecodeSortMode.LAST_HEARD], where rows stay time-ordered; once sorted by + * callsign or SNR the rows interleave cycles and the dividers would be noise. + */ +internal fun showTimeGroupDividers(sortMode: DecodeSortMode): Boolean = + sortMode == DecodeSortMode.LAST_HEARD + +/** + * Index to auto-scroll to when the collapsed list grows, or null to leave the + * scroll position alone. Only [DecodeSortMode.LAST_HEARD] auto-scrolls, and to + * the top (index 0) because that mode puts the newest station first. In the + * other modes a new station can land anywhere, so yanking the viewport would + * fight the operator. + */ +internal fun autoScrollTargetIndex( + sortMode: DecodeSortMode, + size: Int, + previousSize: Int, +): Int? { + if (size <= previousSize || size == 0) return null + return if (sortMode == DecodeSortMode.LAST_HEARD) 0 else null +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt index bbab7bd38..fc2e21950 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt @@ -31,6 +31,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -153,39 +155,56 @@ fun DecodeScreen( ArrayList(messageList ?: arrayListOf()) } - // Apply filter + // Sort mode for the collapsed list — persisted via GeneralVariables.decodeSortMode + // / DB key "decodeSortMode" (same pattern as msgMode / clearDecodesEveryCycle). + var sortMode by rememberSaveable { mutableStateOf(DecodeSortMode.fromConfig(GeneralVariables.decodeSortMode)) } + + // Apply filter, THEN collapse to one row per station. Filtering first keeps + // the visible-station count consistent with the active chip (a station whose + // latest decode is directed but earlier one was a CQ still shows under + // "CQ Calls"). See collapseByStation / filterMessages. val filteredMessages = remember(messages, selectedFilter) { filterMessages(messages, selectedFilter) } + val collapsedMessages = remember(filteredMessages, sortMode) { + collapseByStation(filteredMessages, sortMode) + } // Precompute, once per list change, where the mode-aware time-group dividers - // fall so each row looks its flag up by index instead of recomputing a - // hard-coded 15s slot boundary inline (which collapsed 2-4 FT4/FT2 cycles - // under one divider). - val timeGroupDividers = remember(filteredMessages) { - computeTimeGroupDividers(filteredMessages) + // fall (FT8 15s, FT4 7.5s, FT2 3.75s) so each row looks its flag up by index + // instead of recomputing a slot boundary inline. Computed over the collapsed + // list this LazyColumn iterates, so index is always in bounds. The dividers + // only render in last-heard order (see showTimeGroupDividers) — that gate is + // applied at draw time below. + val timeGroupDividers = remember(collapsedMessages) { + computeTimeGroupDividers(collapsedMessages) } - // Track which keys are new since the previous render (animated on entry only once). + // Track which station rows are new-or-just-updated since the previous render + // (animated once). Keyed by normalized station + utcTime so a station + // re-decoded in a later cycle (new utcTime, same station) re-triggers the + // highlight — that's the "row just updated in place" cue. Only the visible + // keys are retained: accumulating them would grow one entry per station per + // cycle for as long as the screen is open. See advanceRowAnimation. var seenKeys by remember { mutableStateOf(emptySet()) } - val currentKeys = remember(filteredMessages) { - filteredMessages.mapIndexed { i, m -> - "${m.utcTime}_${m.callsignFrom}_${m.freq_hz}_$i" - }.toSet() + val currentKeys = remember(collapsedMessages) { + collapsedMessages.map { rowAnimationKey(it) }.toSet() } - val newKeys = remember(currentKeys) { currentKeys - seenKeys } + val newKeys = remember(currentKeys) { advanceRowAnimation(seenKeys, currentKeys).new } LaunchedEffect(currentKeys) { - seenKeys = seenKeys + currentKeys + seenKeys = advanceRowAnimation(seenKeys, currentKeys).seen } - // Auto-scroll state + // Auto-scroll state. Only "last heard" ordering auto-scrolls (to the top, + // where the newest station sits); the other sorts would yank the viewport. val listState = rememberLazyListState() var previousCount by remember { mutableIntStateOf(0) } - LaunchedEffect(filteredMessages.size) { - if (filteredMessages.size > previousCount && filteredMessages.isNotEmpty()) { - listState.animateScrollToItem(filteredMessages.size - 1) + LaunchedEffect(collapsedMessages.size, sortMode) { + val target = autoScrollTargetIndex(sortMode, collapsedMessages.size, previousCount) + if (target != null) { + listState.animateScrollToItem(target) } - previousCount = filteredMessages.size + previousCount = collapsedMessages.size } // A tapped Needed-DX notification asks us to pre-select that station: reset to the @@ -234,6 +253,17 @@ fun DecodeScreen( ) }, actions = { + IconButton( + onClick = { + sortMode = nextSortMode(sortMode) + GeneralVariables.decodeSortMode = sortMode.configValue + mainViewModel.databaseOpr.writeConfig( + "decodeSortMode", sortMode.configValue.toString(), null, + ) + }, + ) { + SortModeLabel(sortMode) + } IconButton( onClick = { clearEachCycle = !clearEachCycle @@ -288,7 +318,7 @@ fun DecodeScreen( ) // Message list or empty state - if (filteredMessages.isEmpty()) { + if (collapsedMessages.isEmpty()) { EmptyState( selectedFilter = selectedFilter, modifier = Modifier @@ -304,19 +334,24 @@ fun DecodeScreen( verticalArrangement = Arrangement.spacedBy(0.dp), ) { itemsIndexed( - items = filteredMessages, - key = { index, msg -> "${msg.utcTime}_${msg.callsignFrom}_${msg.freq_hz}_$index" }, + items = collapsedMessages, + // Key on the station callsign (stable across cycles) so + // Compose reuses the same row and updates it in place when + // a station is re-decoded, rather than adding a new row. + // Normalized via stationKey so a station whose callsign + // arrives with different case/padding across cycles keeps + // the same key (and so the same row) — see stationKey. + key = { index, msg -> stationKey(msg) ?: "row_$index" }, ) { index, message -> - val rowKey = "${message.utcTime}_${message.callsignFrom}_${message.freq_hz}_$index" - - // Group messages by receive slot (mode-aware: 15s FT8, - // 7.5s FT4, 3.75s FT2). Draw a labeled divider at the first - // row of each new slot; the boundaries were precomputed in - // timeGroupDividers above. It is keyed on the same - // filteredMessages this list iterates, so index is always - // in bounds — index directly rather than masking a - // size mismatch with a getOrElse default. - if (timeGroupDividers[index]) { + val rowKey = rowAnimationKey(message) + + // Group rows by receive slot (mode-aware: 15s FT8, 7.5s + // FT4, 3.75s FT2), but only in "last heard" ordering where + // rows stay time-ordered — the other sorts would scatter + // the dividers. Boundaries were precomputed in + // timeGroupDividers above over this same collapsed list, so + // index is always in bounds. + if (showTimeGroupDividers(sortMode) && timeGroupDividers[index]) { TimeGroupDivider(utcTime = message.utcTime, compact = compactMode) } @@ -435,6 +470,42 @@ private fun TimeGroupDivider(utcTime: Long, compact: Boolean = false) { } } +// --------------------------------------------------------------------------- +// Sort control +// --------------------------------------------------------------------------- + +/** + * Compact label rendered inside the top-bar sort button, showing the active + * [DecodeSortMode] (TIME / CALL / SNR). The button cycles modes on tap; the + * accessibility content description announces the current mode. + */ +@Composable +internal fun SortModeLabel(sortMode: DecodeSortMode) { + val label = stringResource( + when (sortMode) { + DecodeSortMode.LAST_HEARD -> R.string.decode_sort_label_last_heard + DecodeSortMode.CALLSIGN -> R.string.decode_sort_label_callsign + DecodeSortMode.SNR -> R.string.decode_sort_label_snr + }, + ) + val cd = stringResource( + when (sortMode) { + DecodeSortMode.LAST_HEARD -> R.string.decode_sort_cd_last_heard + DecodeSortMode.CALLSIGN -> R.string.decode_sort_cd_callsign + DecodeSortMode.SNR -> R.string.decode_sort_cd_snr + }, + ) + Text( + text = label, + modifier = Modifier.semantics { contentDescription = cd }, + color = Accent, + fontFamily = GeistMonoFamily, + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + letterSpacing = 0.04.sp, + ) +} + // --------------------------------------------------------------------------- // Filter Labels // --------------------------------------------------------------------------- diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 0d4f87f17..1eab0d455 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -159,6 +159,15 @@ Clear decode list UTC : --:--:-- %1$s UTC + + TIME + CALL + SNR + Sort: last heard + Sort: callsign + Sort: SNR All CQ Calls CQ POTA diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapseTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapseTest.kt new file mode 100644 index 000000000..929eed38e --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapseTest.kt @@ -0,0 +1,266 @@ +package radio.ks3ckc.ft8af.ui.decode + +import com.google.common.truth.Truth.assertThat +import com.k1af.ft8af.Ft8Message +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Unit-tests for the pure [collapseByStation] coalescing/sort logic and its + * small helper functions. Robolectric is needed only because [Ft8Message] + * reaches android.util.Log on construction (same reason as [DecodeFilterTest]). + */ +@RunWith(RobolectricTestRunner::class) +class DecodeCollapseTest { + + private fun msg( + from: String, + utc: Long, + snr: Int = 0, + freq: Float = 1000f, + ): Ft8Message = Ft8Message("CQ", from, "FN42").apply { + utcTime = utc + this.snr = snr + freq_hz = freq + } + + // ---- duplicate collapse -------------------------------------------------- + + @Test + fun collapse_keepsOneRowPerStation_withLatestDecode() { + val messages = listOf( + msg("K1ABC", utc = 1000, snr = -5, freq = 1000f), + msg("K2DEF", utc = 1100, snr = 3), + msg("K1ABC", utc = 2000, snr = 12, freq = 1500f), + ) + + val result = collapseByStation(messages, DecodeSortMode.LAST_HEARD) + + assertThat(result.map { it.callsignFrom }).containsExactly("K1ABC", "K2DEF") + val k1 = result.first { it.callsignFrom == "K1ABC" } + // Fields come from the *latest* K1ABC decode (utc 2000), not the first. + assertThat(k1.utcTime).isEqualTo(2000) + assertThat(k1.snr).isEqualTo(12) + assertThat(k1.getFreq_hz()).isEqualTo("1500") + } + + @Test + fun collapse_isCaseInsensitiveByCallsign() { + // The 3-arg Ft8Message constructor upper-cases callFrom, so passing "k1abc" + // through msg() would NOT exercise mixed case — the field has to be set + // directly. It genuinely can be mixed case in production: the 5-arg + // constructor assigns callFrom verbatim, and the hashed-callsign resolution + // path fills callsignFrom from the hash table. + val a = msg("K1ABC", utc = 1000).apply { callsignFrom = " k1abc " } + val b = msg("K1ABC", utc = 2000) + + val result = collapseByStation(listOf(a, b), DecodeSortMode.LAST_HEARD) + + assertThat(result).hasSize(1) + assertThat(result.first().utcTime).isEqualTo(2000) + } + + @Test + fun stationKey_normalisesCaseAndPadding() { + assertThat(stationKey(msg("K1ABC", utc = 1).apply { callsignFrom = " k1abc " })) + .isEqualTo("K1ABC") + assertThat(stationKey(msg("K1ABC", utc = 1).apply { callsignFrom = null })).isNull() + } + + @Test + fun rowAnimationKey_isStableAcrossCallsignCasing() { + // The bug this guards: a row key built from the raw callsignFrom churns when + // the same station arrives as "k1abc" then "K1ABC", recreating the row + // instead of updating it in place. + val lower = msg("K1ABC", utc = 2000).apply { callsignFrom = "k1abc" } + val upper = msg("K1ABC", utc = 2000) + assertThat(rowAnimationKey(lower)).isEqualTo(rowAnimationKey(upper)) + } + + // ---- entry-animation bookkeeping ---------------------------------------- + + @Test + fun advanceRowAnimation_reportsOnlyKeysNotSeenBefore() { + val state = advanceRowAnimation( + previousSeen = setOf("K1ABC_1000"), + current = setOf("K1ABC_1000", "K2DEF_1100"), + ) + assertThat(state.new).containsExactly("K2DEF_1100") + } + + @Test + fun advanceRowAnimation_retainsOnlyTheVisibleKeys() { + // Regression: the previous union grew one entry per station per cycle and + // never shrank, so a screen left open for hours leaked. Retention must be + // bounded by what's actually on screen. + var seen = emptySet() + repeat(1000) { cycle -> + val current = setOf("K1ABC_$cycle", "K2DEF_$cycle") + seen = advanceRowAnimation(seen, current).seen + } + assertThat(seen).hasSize(2) + assertThat(seen).containsExactly("K1ABC_999", "K2DEF_999") + } + + @Test + fun advanceRowAnimation_reAnimatesAStationThatComesBack() { + val first = advanceRowAnimation(emptySet(), setOf("K1ABC_1000")) + val gone = advanceRowAnimation(first.seen, setOf("K2DEF_1100")) + val back = advanceRowAnimation(gone.seen, setOf("K1ABC_2000")) + assertThat(back.new).containsExactly("K1ABC_2000") + } + + @Test + fun collapse_laterDecodeWinsOnTiedUtcTime() { + val first = msg("K1ABC", utc = 5000, snr = -1) + val later = msg("K1ABC", utc = 5000, snr = 20) + + val result = collapseByStation(listOf(first, later), DecodeSortMode.LAST_HEARD) + + assertThat(result).hasSize(1) + assertThat(result.first().snr).isEqualTo(20) + } + + @Test + fun collapse_ignoresOutOfOrderOlderDecode() { + val newer = msg("K1ABC", utc = 5000, snr = 20) + val stale = msg("K1ABC", utc = 1000, snr = -30) + + val result = collapseByStation(listOf(newer, stale), DecodeSortMode.LAST_HEARD) + + assertThat(result).hasSize(1) + assertThat(result.first().utcTime).isEqualTo(5000) + assertThat(result.first().snr).isEqualTo(20) + } + + // ---- sort modes ---------------------------------------------------------- + + @Test + fun sort_lastHeard_mostRecentFirst() { + val messages = listOf( + msg("AA", utc = 1000), + msg("BB", utc = 3000), + msg("CC", utc = 2000), + ) + + val result = collapseByStation(messages, DecodeSortMode.LAST_HEARD) + + assertThat(result.map { it.callsignFrom }).containsExactly("BB", "CC", "AA").inOrder() + } + + @Test + fun sort_callsign_alphabetical() { + val messages = listOf( + msg("W9XYZ", utc = 3000), + msg("K1ABC", utc = 1000), + msg("N0RC", utc = 2000), + ) + + val result = collapseByStation(messages, DecodeSortMode.CALLSIGN) + + assertThat(result.map { it.callsignFrom }).containsExactly("K1ABC", "N0RC", "W9XYZ").inOrder() + } + + @Test + fun sort_snr_strongestFirst_unknownSinks() { + val messages = listOf( + msg("AA", utc = 1000, snr = -10), + msg("BB", utc = 1000, snr = 15), + msg("CC", utc = 1000).apply { snr = Ft8Message.SNR_UNKNOWN }, + msg("DD", utc = 1000, snr = 0), + ) + + val result = collapseByStation(messages, DecodeSortMode.SNR) + + // 15, 0, -10, then unknown last. + assertThat(result.map { it.callsignFrom }).containsExactly("BB", "DD", "AA", "CC").inOrder() + } + + // ---- stable ordering on ties --------------------------------------------- + + @Test + fun sort_lastHeard_stableOnTiedTime() { + val messages = listOf( + msg("AA", utc = 1000), + msg("BB", utc = 1000), + msg("CC", utc = 1000), + ) + + val result = collapseByStation(messages, DecodeSortMode.LAST_HEARD) + + // Equal times keep first-appearance order. + assertThat(result.map { it.callsignFrom }).containsExactly("AA", "BB", "CC").inOrder() + } + + @Test + fun sort_snr_stableOnTiedSnr() { + val messages = listOf( + msg("AA", utc = 1000, snr = 5), + msg("BB", utc = 2000, snr = 5), + msg("CC", utc = 3000, snr = 5), + ) + + val result = collapseByStation(messages, DecodeSortMode.SNR) + + assertThat(result.map { it.callsignFrom }).containsExactly("AA", "BB", "CC").inOrder() + } + + @Test + fun collapse_dropsBlankCallsigns() { + val messages = listOf( + msg("K1ABC", utc = 1000), + Ft8Message(1).apply { callsignFrom = null; utcTime = 2000 }, + ) + + val result = collapseByStation(messages, DecodeSortMode.LAST_HEARD) + + assertThat(result.map { it.callsignFrom }).containsExactly("K1ABC") + } + + @Test + fun collapse_emptyInputYieldsEmpty() { + assertThat(collapseByStation(emptyList(), DecodeSortMode.SNR)).isEmpty() + } + + // ---- helper functions ---------------------------------------------------- + + @Test + fun sortMode_configRoundTrips() { + for (mode in DecodeSortMode.entries) { + assertThat(DecodeSortMode.fromConfig(mode.configValue)).isEqualTo(mode) + } + } + + @Test + fun sortMode_fromConfig_defaultsToLastHeard() { + assertThat(DecodeSortMode.fromConfig(-1)).isEqualTo(DecodeSortMode.LAST_HEARD) + assertThat(DecodeSortMode.fromConfig(99)).isEqualTo(DecodeSortMode.LAST_HEARD) + } + + @Test + fun nextSortMode_cyclesThroughAllThenWraps() { + assertThat(nextSortMode(DecodeSortMode.LAST_HEARD)).isEqualTo(DecodeSortMode.CALLSIGN) + assertThat(nextSortMode(DecodeSortMode.CALLSIGN)).isEqualTo(DecodeSortMode.SNR) + assertThat(nextSortMode(DecodeSortMode.SNR)).isEqualTo(DecodeSortMode.LAST_HEARD) + } + + @Test + fun showTimeGroupDividers_onlyInLastHeard() { + assertThat(showTimeGroupDividers(DecodeSortMode.LAST_HEARD)).isTrue() + assertThat(showTimeGroupDividers(DecodeSortMode.CALLSIGN)).isFalse() + assertThat(showTimeGroupDividers(DecodeSortMode.SNR)).isFalse() + } + + @Test + fun autoScrollTarget_onlyLastHeardScrollsToTopWhenGrown() { + assertThat(autoScrollTargetIndex(DecodeSortMode.LAST_HEARD, size = 3, previousSize = 2)).isEqualTo(0) + // No growth -> no scroll. + assertThat(autoScrollTargetIndex(DecodeSortMode.LAST_HEARD, size = 2, previousSize = 2)).isNull() + // Other modes never auto-scroll. + assertThat(autoScrollTargetIndex(DecodeSortMode.CALLSIGN, size = 3, previousSize = 2)).isNull() + assertThat(autoScrollTargetIndex(DecodeSortMode.SNR, size = 3, previousSize = 2)).isNull() + // Empty list -> no scroll. + assertThat(autoScrollTargetIndex(DecodeSortMode.LAST_HEARD, size = 0, previousSize = 0)).isNull() + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreenTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreenTest.kt index ae1ef704c..8c2781dd6 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreenTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreenTest.kt @@ -3,6 +3,7 @@ package radio.ks3ckc.ft8af.ui.decode import android.content.Context import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -53,4 +54,42 @@ class DecodeScreenTest { composeRule.onNodeWithText(title).assertIsDisplayed() } + + @Test + fun sortLabel_showsActiveModeTextAndDescription() { + composeRule.setContent { + SortModeLabel(sortMode = DecodeSortMode.SNR) + } + + composeRule.onNodeWithText(context.getString(R.string.decode_sort_label_snr)) + .assertIsDisplayed() + composeRule.onNodeWithContentDescription(context.getString(R.string.decode_sort_cd_snr)) + .assertIsDisplayed() + } + + @Test + fun sortLabel_lastHeardShowsTimeLabelAndDescription() { + composeRule.setContent { + SortModeLabel(sortMode = DecodeSortMode.LAST_HEARD) + } + + composeRule.onNodeWithText(context.getString(R.string.decode_sort_label_last_heard)) + .assertIsDisplayed() + composeRule.onNodeWithContentDescription(context.getString(R.string.decode_sort_cd_last_heard)) + .assertIsDisplayed() + } + + @Test + fun sortLabel_callsignShowsTextAndDescription() { + // The third mode: without this, CALLSIGN was the one option whose label and + // accessibility description nothing verified. + composeRule.setContent { + SortModeLabel(sortMode = DecodeSortMode.CALLSIGN) + } + + composeRule.onNodeWithText(context.getString(R.string.decode_sort_label_callsign)) + .assertIsDisplayed() + composeRule.onNodeWithContentDescription(context.getString(R.string.decode_sort_cd_callsign)) + .assertIsDisplayed() + } } From 335b315e2cbf8a276941deeb089a3e928d9d34b4 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 21:36:35 -0500 Subject: [PATCH 040/113] Message Creator core: WSJT-X QSY/canned message templating + recognition (#633) * Add SpecialMessage templating + recognition for WSJT-X QSY/canned messages Introduce com.k1af.ft8af.message.SpecialMessage, a pure, Android-free logic module that builds and recognises WSJT-X-style special messages (QSY requests and canned "General"-tab messages) inside ordinary 13-char FT8/FT4 free-text frames. This is the tested core the Message Creator UI, QSY Monitor list, and message-popup layer will wrap (mirroring the AlertDecisions pure-logic pattern). - buildQsyRequest / buildQsyRequestForFreqKhz: compose ". " (e.g. "K1ABC.20 074" = QSY K1ABC to 14.074 MHz), gated on the operator's IARU region so illegal band edges are rejected. - buildCannedMessage: compose " " from a curated canned set. - parse / parseFor: strict decode-side recognition of QSY and canned messages, filterable to those addressed to the operator. - Add GeneralVariables.iaruRegion (default Region 2) to hold the setting. Fully unit-tested in SpecialMessageTest (35 cases, pure JVM). Co-Authored-By: Claude Opus 4.8 (1M context) * Review fixes: drop the unused import, wire iaruRegion config loading - java.util.EnumSet was imported but never referenced in SpecialMessage. - Added the "iaruRegion" branch to DatabaseOpr's config hydration so a persisted region survives relaunch. Read side only: the Settings row that writes the key lands with the Message-Creator UI, but having the read here means that follow-up doesn't have to remember to touch this block. parseConfigInt + regionFromNumber means junk or out-of-range config degrades to region 2 instead of throwing; pinned by a new test. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/GeneralVariables.java | 1 + .../com/k1af/ft8af/database/DatabaseOpr.java | 9 + .../k1af/ft8af/message/SpecialMessage.java | 499 ++++++++++++++++++ .../DatabaseOprParseConfigIntTest.java | 26 + .../ft8af/message/SpecialMessageTest.java | 292 ++++++++++ 5 files changed, 827 insertions(+) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/message/SpecialMessage.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/message/SpecialMessageTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 7a0f0725f..20da4d9e8 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -452,6 +452,7 @@ public static Context getMainContext() { public static int manualTimeCorrectionMs = 0;//Manual clock correction (ms) applied to UtcTimer.delay; for field use without internet NTP. Range -2000..2000. See TimeSyncSettings. public static boolean earlyDecode = true;//Fast turnaround: decode a shorter RX window so CQ decodes appear ~1s before the cycle boundary, enabling a next-slot reply. public static int operatingMode = FT8Common.FT8_MODE;//Current operating mode (FT8Common.FT8_MODE / FT4_MODE); persisted as config "operatingMode". + public static int iaruRegion = 2;//Operator's IARU region (1/2/3), used to gate Message-Creator QSY frequency options to legal band edges. Default 2 (the Americas). See com.k1af.ft8af.message.SpecialMessage. public static boolean autoCQAfterQSO = false;//Auto-CQ: keep calling CQ after each completed QSO (chain without re-tapping). Refreshes the TX watchdog per QSO and forces pure CQ (ignores Hunt). public static int civAddress = 0xa4;//CI-V address public static int baudRate = 19200;//Baud rate diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 97aeaf2a6..911afdfb0 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2787,6 +2787,15 @@ protected Void doInBackground(Void... voids) { GeneralVariables.operatingMode = FT8Common.FT8_MODE; } } + if (name.equalsIgnoreCase("iaruRegion")) {//Operator's IARU region (1/2/3), defaults 2 + // Load side only for now: the Settings row that writes this key lands + // with the Message-Creator UI. Wiring the read here means a value + // persisted by that follow-up survives relaunch without a second edit + // to this hydration block. regionFromNumber maps anything out of range + // back to region 2, so a junk config value degrades instead of throwing. + GeneralVariables.iaruRegion = com.k1af.ft8af.message.SpecialMessage + .regionFromNumber(parseConfigInt(result, 2)).number; + } if (name.equalsIgnoreCase("autoCQAfterQSO")) {//Auto-CQ after each completed QSO, defaults off GeneralVariables.autoCQAfterQSO = result.equals("1"); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/message/SpecialMessage.java b/ft8af/app/src/main/java/com/k1af/ft8af/message/SpecialMessage.java new file mode 100644 index 000000000..e6f307871 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/message/SpecialMessage.java @@ -0,0 +1,499 @@ +package com.k1af.ft8af.message; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Pure, Android-free templating + recognition logic for WSJT-X-style + * special messages (Message Creator, QSY Monitor, message popups). + * + *

WSJT-X lets an operator invite a QSO partner to move to another band / + * frequency (a QSY request) or send a short canned message, all + * riding inside an ordinary FT8/FT4 free-text frame ({@code i3=0, + * n3=0}, 13 characters max — see {@code GenerateFT8.packFreeTextTo77}). There + * is no new on-air protocol; the whole feature is UI + string templating + + * decode-side pattern matching. This class owns exactly that string logic so it + * can be unit-tested off-device; the Compose/dialog UI, the transmit queue hook + * and the notification/toast layer are thin wrappers that call in here (mirrors + * {@link com.k1af.ft8af.alert.AlertDecisions}). + * + *

On-air formats

+ * Every special message built by the creator leads with the addressee callsign + * so the receiver's monitor/popup can tell it is aimed at them: + *
    + *
  • QSY request — {@code ". "}, e.g. + * {@code "K1ABC.20 074"} meaning "K1ABC, QSY to the 20 m band, + * 14.074 MHz". {@code } is the metre designation without the + * {@code m}; {@code } is the kHz offset (000-999) above the band's + * base frequency. This mirrors the compact structure WSJT-X uses in its + * QSY shorthand (band code + 3-digit kHz) while staying within the 13-char + * free-text budget.
  • + *
  • Canned message — {@code " "}, e.g. + * {@code "K1ABC 73 GL"}, where {@code } is one of + * {@link #CANNED_MESSAGES}.
  • + *
+ * Because the leading {@code } field is not a hashed / structured + * callsign field but plain text, these frames still decode as ordinary free + * text on any WSJT-X-family receiver; only the recognition here is FT8AF-added. + * + *

QSY frequency options are gated on the operator's {@link IaruRegion}: the + * three IARU regions have different band edges (e.g. 40 m stops at 7.200 MHz in + * Region 1 but runs to 7.300 in Regions 2/3), so a QSY the sender could make may + * be illegal for the recipient. {@link #buildQsyRequest} rejects a band / + * frequency not allocated in the given region. + */ +public final class SpecialMessage { + + private SpecialMessage() {} + + /** Maximum length of an FT8/FT4 free-text frame, in characters. */ + public static final int FREE_TEXT_LIMIT = 13; + + /** The three IARU regions; band plans differ between them. */ + public enum IaruRegion { + /** Europe, Africa, Middle East, northern Asia. */ + REGION_1(1), + /** The Americas. */ + REGION_2(2), + /** Asia-Pacific. */ + REGION_3(3); + + public final int number; + + IaruRegion(int number) { + this.number = number; + } + } + + /** + * Map a persisted region number (1/2/3) to an {@link IaruRegion}, defaulting + * to {@link IaruRegion#REGION_2} (the Americas) for any out-of-range value — + * matching {@code GeneralVariables.iaruRegion}'s default. + */ + public static IaruRegion regionFromNumber(int number) { + switch (number) { + case 1: + return IaruRegion.REGION_1; + case 3: + return IaruRegion.REGION_3; + case 2: + default: + return IaruRegion.REGION_2; + } + } + + /** + * Curated, mobile-friendly set of canned "General"-tab messages. Each is + * ≤ a length that still leaves room for the addressee callsign inside the + * 13-char frame. Order is display order for the picker. + */ + public static final List CANNED_MESSAGES = Collections.unmodifiableList(Arrays.asList( + "73", + "RR73", + "73 GL", + "TNX 73", + "GL", + "QSL?", + "QSL", + "AGN?", + "PSE AGN", + "SRI QRM", + "SK", + "5NN" + )); + + /** + * A band that a QSY request may target. {@link #baseKhz} is the frequency the + * 3-digit offset is added to (the band's low edge, rounded to the MHz the + * digital sub-bands live in); {@link #maxOffsetForRegion} bounds the offset by + * the region's upper band edge. + */ + public static final class QsyBand { + /** On-air code, e.g. {@code "20"} — the metre band without the {@code m}. */ + public final String code; + /** Human label, e.g. {@code "20m"}. */ + public final String meters; + /** Base frequency in kHz that the offset is added to, e.g. {@code 14000}. */ + public final long baseKhz; + /** Per-region upper offset (kHz above {@link #baseKhz}); absent => not allocated. */ + private final Map maxOffset; + + QsyBand(String code, long baseKhz, Map maxOffset) { + this.code = code; + this.meters = code + "m"; + this.baseKhz = baseKhz; + this.maxOffset = maxOffset; + } + + /** True when this band is allocated in {@code region}. */ + public boolean availableIn(IaruRegion region) { + return maxOffset.containsKey(region); + } + + /** + * Largest legal offset (kHz above {@link #baseKhz}) in {@code region}, or + * -1 when the band is not allocated there. The offset is additionally + * capped at 999 by the 3-digit field width. + */ + public int maxOffsetForRegion(IaruRegion region) { + Integer m = maxOffset.get(region); + if (m == null) { + return -1; + } + return Math.min(m, 999); + } + } + + // Region -> upper offset (kHz above base) helpers, kept terse for the table below. + private static Map allRegions(int r1, int r2, int r3) { + Map m = new LinkedHashMap<>(); + m.put(IaruRegion.REGION_1, r1); + m.put(IaruRegion.REGION_2, r2); + m.put(IaruRegion.REGION_3, r3); + return m; + } + + /** + * Band table, in picker order. Bases sit at the MHz the FT8/FT4 digital + * sub-bands occupy, so a 3-digit offset (0-999) covers every digital dial + * frequency on the band even where the full allocation is wider (10 m, 6 m, + * 2 m). Upper offsets encode the region-specific band edges used for gating. + */ + private static final List BANDS = buildBands(); + + private static List buildBands() { + List b = new ArrayList<>(); + // code baseKhz R1 R2 R3 (upper offset, kHz above base) + b.add(new QsyBand("160", 1800L, allRegions(200, 200, 200))); + b.add(new QsyBand("80", 3500L, allRegions(300, 500, 400))); + b.add(new QsyBand("40", 7000L, allRegions(200, 300, 300))); + b.add(new QsyBand("30", 10100L, allRegions(50, 50, 50))); + b.add(new QsyBand("20", 14000L, allRegions(350, 350, 350))); + b.add(new QsyBand("17", 18068L, allRegions(100, 100, 100))); + b.add(new QsyBand("15", 21000L, allRegions(450, 450, 450))); + b.add(new QsyBand("12", 24890L, allRegions(100, 100, 100))); + b.add(new QsyBand("10", 28000L, allRegions(999, 999, 999))); + b.add(new QsyBand("6", 50000L, allRegions(999, 999, 999))); + b.add(new QsyBand("2", 144000L, allRegions(999, 999, 999))); + return Collections.unmodifiableList(b); + } + + /** All known QSY bands, in picker order (regardless of region). */ + public static List allBands() { + return BANDS; + } + + /** Bands the picker should offer for {@code region}, in display order. */ + public static List bandsForRegion(IaruRegion region) { + List out = new ArrayList<>(); + for (QsyBand band : BANDS) { + if (band.availableIn(region)) { + out.add(band); + } + } + return out; + } + + /** Look up a band by its on-air code (e.g. {@code "20"}); null if unknown. */ + public static QsyBand bandByCode(String code) { + if (code == null) { + return null; + } + String c = code.trim(); + for (QsyBand band : BANDS) { + if (band.code.equals(c)) { + return band; + } + } + return null; + } + + /** + * Build the on-air free text for a QSY request. + * + * @param dxCall the addressee (DX Call field); normalised to upper case + * @param bandCode band code, e.g. {@code "20"} (see {@link QsyBand#code}) + * @param offsetKhz kHz above the band base, 0-999 and within the region edge + * @param region the operator's IARU region, used to gate the frequency + * @return the free-text string, e.g. {@code "K1ABC.20 074"} + * @throws IllegalArgumentException if the call is missing, the band is unknown + * or not allocated in {@code region}, the offset is out of range, or + * the result would exceed {@link #FREE_TEXT_LIMIT} characters + */ + public static String buildQsyRequest(String dxCall, String bandCode, int offsetKhz, IaruRegion region) { + String call = normCall(dxCall); + if (call.isEmpty()) { + throw new IllegalArgumentException("dxCall is required"); + } + if (region == null) { + throw new IllegalArgumentException("region is required"); + } + QsyBand band = bandByCode(bandCode); + if (band == null) { + throw new IllegalArgumentException("unknown band code: " + bandCode); + } + if (!band.availableIn(region)) { + throw new IllegalArgumentException( + band.meters + " is not allocated in IARU Region " + region.number); + } + int max = band.maxOffsetForRegion(region); + if (offsetKhz < 0 || offsetKhz > max) { + throw new IllegalArgumentException( + "offset " + offsetKhz + " kHz outside 0.." + max + " for " + + band.meters + " in Region " + region.number); + } + String text = String.format(Locale.US, "%s.%s %03d", call, band.code, offsetKhz); + if (text.length() > FREE_TEXT_LIMIT) { + throw new IllegalArgumentException( + "message \"" + text + "\" exceeds " + FREE_TEXT_LIMIT + " chars"); + } + return text; + } + + /** + * Build a QSY request from an absolute frequency by resolving which band it + * lands in. Convenience over {@link #buildQsyRequest(String, String, int, + * IaruRegion)} for callers that already hold a dial frequency in kHz. + * + * @throws IllegalArgumentException if no allocated band in {@code region} + * contains {@code freqKhz} + */ + public static String buildQsyRequestForFreqKhz(String dxCall, long freqKhz, IaruRegion region) { + if (region == null) { + throw new IllegalArgumentException("region is required"); + } + for (QsyBand band : BANDS) { + if (!band.availableIn(region)) { + continue; + } + long offset = freqKhz - band.baseKhz; + if (offset >= 0 && offset <= band.maxOffsetForRegion(region)) { + return buildQsyRequest(dxCall, band.code, (int) offset, region); + } + } + throw new IllegalArgumentException( + freqKhz + " kHz is not in any band allocated in Region " + region.number); + } + + /** + * Build the on-air free text for a canned message. + * + * @param dxCall the addressee; normalised to upper case + * @param canned the canned payload (should be one of {@link #CANNED_MESSAGES}) + * @return e.g. {@code "K1ABC 73 GL"} + * @throws IllegalArgumentException if the call/text is missing or the result + * exceeds {@link #FREE_TEXT_LIMIT} characters + */ + public static String buildCannedMessage(String dxCall, String canned) { + String call = normCall(dxCall); + if (call.isEmpty()) { + throw new IllegalArgumentException("dxCall is required"); + } + String payload = canned == null ? "" : canned.trim().toUpperCase(Locale.US); + if (payload.isEmpty()) { + throw new IllegalArgumentException("canned text is required"); + } + String text = call + " " + payload; + if (text.length() > FREE_TEXT_LIMIT) { + throw new IllegalArgumentException( + "message \"" + text + "\" exceeds " + FREE_TEXT_LIMIT + " chars"); + } + return text; + } + + /** + * Whether {@code call} would fit an addressed canned/QSY message of a given + * payload width — a UI convenience so the picker can disable options that + * can't be sent to the current DX call. {@code payloadLen} is the length of + * the part after the leading {@code " "} / {@code "."}. + */ + public static boolean fitsAddressed(String dxCall, int payloadLen) { + String call = normCall(dxCall); + // +1 for the separator (space or period) between call and payload. + return !call.isEmpty() && call.length() + 1 + payloadLen <= FREE_TEXT_LIMIT; + } + + /** The kind of special message a decoded free-text frame carries. */ + public enum Kind { QSY, CANNED } + + /** + * A recognised special message. For {@link Kind#QSY}, {@link #bandCode}, + * {@link #meters} and {@link #freqKhz} describe the requested frequency; for + * {@link Kind#CANNED}, {@link #text} is the canned payload. + */ + public static final class Parsed { + public final Kind kind; + /** Addressee callsign (upper case). */ + public final String toCall; + /** QSY only: band code, e.g. {@code "20"}; null for canned. */ + public final String bandCode; + /** QSY only: human band label, e.g. {@code "20m"}; null for canned. */ + public final String meters; + /** QSY only: requested dial frequency in kHz; 0 for canned. */ + public final long freqKhz; + /** Canned only: the payload text; null for QSY. */ + public final String text; + + private Parsed(Kind kind, String toCall, String bandCode, String meters, long freqKhz, String text) { + this.kind = kind; + this.toCall = toCall; + this.bandCode = bandCode; + this.meters = meters; + this.freqKhz = freqKhz; + this.text = text; + } + + static Parsed qsy(String toCall, QsyBand band, long freqKhz) { + return new Parsed(Kind.QSY, toCall, band.code, band.meters, freqKhz, null); + } + + static Parsed canned(String toCall, String text) { + return new Parsed(Kind.CANNED, toCall, null, null, 0, text); + } + + /** Frequency as MHz string, e.g. {@code "14.074"} (QSY only). */ + public String freqMhz() { + return String.format(Locale.US, "%d.%03d", freqKhz / 1000, freqKhz % 1000); + } + + /** Short one-line summary for the QSY Monitor / popup. */ + public String summary() { + if (kind == Kind.QSY) { + return meters + " " + freqMhz(); + } + return text; + } + } + + /** + * Recognise a special message in a decoded free-text frame. + * + *

Recognition is intentionally strict to keep false positives off the QSY + * Monitor: a QSY request must match {@code . } with a known + * band code and an in-range offset, and a canned message must be a known + * callsign followed by exactly one of {@link #CANNED_MESSAGES}. Ordinary + * structured FT8 exchanges never reach here — they decode as {@code i3!=0} + * frames, not free text. + * + * @param freeText the frame's free-text content (the raw 13-char field) + * @return the parsed message, or null if it is not a recognised special one + */ + public static Parsed parse(String freeText) { + if (freeText == null) { + return null; + } + String s = freeText.trim().toUpperCase(Locale.US); + if (s.isEmpty()) { + return null; + } + // Collapse the run of trailing pad spaces getMessageText() adds, and split. + int space = s.indexOf(' '); + if (space < 0) { + return null; + } + String head = s.substring(0, space); + String tail = s.substring(space + 1).trim(); + if (tail.isEmpty()) { + return null; + } + + // QSY: head is ".", tail is a 1-3 digit offset. + int dot = head.indexOf('.'); + if (dot > 0 && dot < head.length() - 1) { + String call = head.substring(0, dot); + String bandCode = head.substring(dot + 1); + QsyBand band = bandByCode(bandCode); + if (band != null && isPlausibleCall(call) && isDigits(tail) && tail.length() <= 3) { + int offset = Integer.parseInt(tail); + // Accept any offset the 3-digit field can hold that lands in the + // band; region-gating is a send-side concern, a received request + // is shown as-is (the operator decides whether to honour it). + if (offset >= 0 && offset <= 999) { + return Parsed.qsy(call, band, band.baseKhz + offset); + } + } + } + + // Canned: head is a plausible callsign, tail is a known canned payload. + if (isPlausibleCall(head) && isCanned(tail)) { + return Parsed.canned(head, tail); + } + return null; + } + + /** + * Convenience: parse and, if it is a special message addressed to + * {@code myCall}, return it; otherwise null. Used by the QSY Monitor / popup + * layer, which only cares about messages aimed at the operator. + */ + public static Parsed parseFor(String freeText, String myCall) { + Parsed p = parse(freeText); + if (p == null) { + return null; + } + String mine = normCall(myCall); + if (!mine.isEmpty() && p.toCall.equals(mine)) { + return p; + } + return null; + } + + /** Whether {@code text} exactly matches one of the canned messages (case-insensitive). */ + public static boolean isCanned(String text) { + if (text == null) { + return false; + } + String t = text.trim().toUpperCase(Locale.US); + for (String canned : CANNED_MESSAGES) { + if (canned.equals(t)) { + return true; + } + } + return false; + } + + // ---- small helpers ---- + + private static String normCall(String call) { + return call == null ? "" : call.trim().toUpperCase(Locale.US); + } + + /** A conservative callsign shape: 3-11 chars of the call alphabet {@code [A-Z0-9/]}. */ + private static boolean isPlausibleCall(String s) { + if (s == null || s.length() < 3 || s.length() > 11) { + return false; + } + boolean hasLetter = false; + boolean hasDigit = false; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c >= 'A' && c <= 'Z') { + hasLetter = true; + } else if (c >= '0' && c <= '9') { + hasDigit = true; + } else if (c != '/') { + return false; + } + } + // Real callsigns carry at least one letter and one digit. + return hasLetter && hasDigit; + } + + private static boolean isDigits(String s) { + if (s == null || s.isEmpty()) { + return false; + } + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + return true; + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprParseConfigIntTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprParseConfigIntTest.java index a26c0ace0..ba8fe7666 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprParseConfigIntTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprParseConfigIntTest.java @@ -125,4 +125,30 @@ public void volumeValue_scalingKeepsUnityFallback() { assertThat(DatabaseOpr.parseConfigFloat("loud", 100f) / 100f).isEqualTo(1.0f); // garbage assertThat(DatabaseOpr.parseConfigFloat("50", 100f) / 100f).isEqualTo(0.5f); // valid honored } + + /** + * The "iaruRegion" config key is hydrated as + * {@code regionFromNumber(parseConfigInt(value, 2)).number}. Pin that composition: + * any stored value — absent, junk, or out of the 1..3 range — must land on a legal + * region rather than throwing or leaving the field with a nonsense number that the + * QSY band gating would then read. + */ + @Test + public void iaruRegionHydrationAlwaysYieldsALegalRegion() { + assertThat(hydrateIaruRegion("1")).isEqualTo(1); + assertThat(hydrateIaruRegion("3")).isEqualTo(3); + // Unset / junk / out of range all degrade to region 2 (the Americas). + assertThat(hydrateIaruRegion("")).isEqualTo(2); + assertThat(hydrateIaruRegion(null)).isEqualTo(2); + assertThat(hydrateIaruRegion("region one")).isEqualTo(2); + assertThat(hydrateIaruRegion("0")).isEqualTo(2); + assertThat(hydrateIaruRegion("7")).isEqualTo(2); + assertThat(hydrateIaruRegion("-1")).isEqualTo(2); + } + + /** Mirrors the "iaruRegion" branch of DatabaseOpr's config hydration loop. */ + private static int hydrateIaruRegion(String stored) { + return com.k1af.ft8af.message.SpecialMessage + .regionFromNumber(DatabaseOpr.parseConfigInt(stored, 2)).number; + } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/message/SpecialMessageTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/message/SpecialMessageTest.java new file mode 100644 index 000000000..241085ec3 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/message/SpecialMessageTest.java @@ -0,0 +1,292 @@ +package com.k1af.ft8af.message; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.k1af.ft8af.message.SpecialMessage.IaruRegion; +import com.k1af.ft8af.message.SpecialMessage.Kind; +import com.k1af.ft8af.message.SpecialMessage.Parsed; +import com.k1af.ft8af.message.SpecialMessage.QsyBand; + +import org.junit.Test; + +/** Pure-JVM tests for {@link SpecialMessage} (no Android runtime needed). */ +public class SpecialMessageTest { + + // ---- regionFromNumber ---- + + @Test + public void regionFromNumber_mapsKnownValues() { + assertThat(SpecialMessage.regionFromNumber(1)).isEqualTo(IaruRegion.REGION_1); + assertThat(SpecialMessage.regionFromNumber(2)).isEqualTo(IaruRegion.REGION_2); + assertThat(SpecialMessage.regionFromNumber(3)).isEqualTo(IaruRegion.REGION_3); + } + + @Test + public void regionFromNumber_defaultsToRegion2() { + assertThat(SpecialMessage.regionFromNumber(0)).isEqualTo(IaruRegion.REGION_2); + assertThat(SpecialMessage.regionFromNumber(9)).isEqualTo(IaruRegion.REGION_2); + assertThat(SpecialMessage.regionFromNumber(-1)).isEqualTo(IaruRegion.REGION_2); + } + + // ---- buildQsyRequest ---- + + @Test + public void buildQsyRequest_formatsCallBandOffset() { + String msg = SpecialMessage.buildQsyRequest("k1abc", "20", 74, IaruRegion.REGION_2); + assertThat(msg).isEqualTo("K1ABC.20 074"); + assertThat(msg.length()).isAtMost(SpecialMessage.FREE_TEXT_LIMIT); + } + + @Test + public void buildQsyRequest_zeroPadsOffset() { + assertThat(SpecialMessage.buildQsyRequest("W5X", "6", 5, IaruRegion.REGION_2)) + .isEqualTo("W5X.6 005"); + } + + @Test + public void buildQsyRequest_rejectsMissingCall() { + assertThrows(IllegalArgumentException.class, + () -> SpecialMessage.buildQsyRequest(" ", "20", 74, IaruRegion.REGION_2)); + } + + @Test + public void buildQsyRequest_rejectsNullRegion() { + assertThrows(IllegalArgumentException.class, + () -> SpecialMessage.buildQsyRequest("K1ABC", "20", 74, null)); + } + + @Test + public void buildQsyRequest_rejectsUnknownBand() { + assertThrows(IllegalArgumentException.class, + () -> SpecialMessage.buildQsyRequest("K1ABC", "99", 74, IaruRegion.REGION_2)); + } + + @Test + public void buildQsyRequest_rejectsOffsetAboveRegionEdge() { + // 40m in Region 1 stops at +200 (7.200 MHz); +250 is legal in Region 2. + assertThrows(IllegalArgumentException.class, + () -> SpecialMessage.buildQsyRequest("K1ABC", "40", 250, IaruRegion.REGION_1)); + assertThat(SpecialMessage.buildQsyRequest("K1ABC", "40", 250, IaruRegion.REGION_2)) + .isEqualTo("K1ABC.40 250"); + } + + @Test + public void buildQsyRequest_rejectsNegativeOffset() { + assertThrows(IllegalArgumentException.class, + () -> SpecialMessage.buildQsyRequest("K1ABC", "20", -1, IaruRegion.REGION_2)); + } + + @Test + public void buildQsyRequest_rejectsWhenTooLongForCall() { + // "KA1ABC.160 074" is 14 chars > 13. + assertThrows(IllegalArgumentException.class, + () -> SpecialMessage.buildQsyRequest("KA1ABC", "160", 74, IaruRegion.REGION_2)); + } + + // ---- buildQsyRequestForFreqKhz ---- + + @Test + public void buildQsyRequestForFreq_resolvesBand() { + assertThat(SpecialMessage.buildQsyRequestForFreqKhz("K1ABC", 14074, IaruRegion.REGION_2)) + .isEqualTo("K1ABC.20 074"); + assertThat(SpecialMessage.buildQsyRequestForFreqKhz("K1ABC", 50313, IaruRegion.REGION_2)) + .isEqualTo("K1ABC.6 313"); + } + + @Test + public void buildQsyRequestForFreq_rejectsOutOfBandFreq() { + // 13000 kHz is between 30m and 20m — not in any allocation. + assertThrows(IllegalArgumentException.class, + () -> SpecialMessage.buildQsyRequestForFreqKhz("K1ABC", 13000, IaruRegion.REGION_2)); + } + + // ---- buildCannedMessage ---- + + @Test + public void buildCanned_addressesTheCall() { + assertThat(SpecialMessage.buildCannedMessage("k1abc", "73 GL")).isEqualTo("K1ABC 73 GL"); + } + + @Test + public void buildCanned_rejectsEmptyText() { + assertThrows(IllegalArgumentException.class, + () -> SpecialMessage.buildCannedMessage("K1ABC", " ")); + } + + @Test + public void buildCanned_rejectsWhenTooLong() { + // "KA1XYZ SRI QRM" is 14 chars > 13. + assertThrows(IllegalArgumentException.class, + () -> SpecialMessage.buildCannedMessage("KA1XYZ", "SRI QRM")); + } + + // ---- fitsAddressed ---- + + @Test + public void fitsAddressed_accountsForSeparator() { + // "K1ABC" (5) + sep (1) + payload(6) = 12 <= 13. + assertThat(SpecialMessage.fitsAddressed("K1ABC", 6)).isTrue(); + // "KA1ABC" (6) + 1 + 7 = 14 > 13. + assertThat(SpecialMessage.fitsAddressed("KA1ABC", 7)).isFalse(); + assertThat(SpecialMessage.fitsAddressed("", 1)).isFalse(); + } + + // ---- bands / region filtering ---- + + @Test + public void bandsForRegion_allBandsAllocatedEverywhere() { + // Every band in the table is allocated in all three regions. + assertThat(SpecialMessage.bandsForRegion(IaruRegion.REGION_1)) + .hasSize(SpecialMessage.allBands().size()); + } + + @Test + public void bandByCode_knownAndUnknown() { + QsyBand b = SpecialMessage.bandByCode("20"); + assertThat(b).isNotNull(); + assertThat(b.baseKhz).isEqualTo(14000L); + assertThat(b.meters).isEqualTo("20m"); + assertThat(SpecialMessage.bandByCode("99")).isNull(); + assertThat(SpecialMessage.bandByCode(null)).isNull(); + } + + @Test + public void maxOffsetForRegion_cappedAt999AndRegionEdge() { + QsyBand ten = SpecialMessage.bandByCode("10"); + assertThat(ten.maxOffsetForRegion(IaruRegion.REGION_2)).isEqualTo(999); + QsyBand forty = SpecialMessage.bandByCode("40"); + assertThat(forty.maxOffsetForRegion(IaruRegion.REGION_1)).isEqualTo(200); + assertThat(forty.maxOffsetForRegion(IaruRegion.REGION_2)).isEqualTo(300); + } + + // ---- parse: QSY ---- + + @Test + public void parse_recognisesQsyRequest() { + Parsed p = SpecialMessage.parse("K1ABC.20 074"); + assertThat(p).isNotNull(); + assertThat(p.kind).isEqualTo(Kind.QSY); + assertThat(p.toCall).isEqualTo("K1ABC"); + assertThat(p.bandCode).isEqualTo("20"); + assertThat(p.meters).isEqualTo("20m"); + assertThat(p.freqKhz).isEqualTo(14074L); + assertThat(p.freqMhz()).isEqualTo("14.074"); + assertThat(p.summary()).isEqualTo("20m 14.074"); + } + + @Test + public void parse_handlesPaddedFreeTextField() { + // getMessageText() right-pads free text to 13 chars; parse must cope. + Parsed p = SpecialMessage.parse("K1ABC.6 313 "); + assertThat(p).isNotNull(); + assertThat(p.kind).isEqualTo(Kind.QSY); + assertThat(p.freqKhz).isEqualTo(50313L); + } + + @Test + public void parse_qsyIsCaseInsensitive() { + Parsed p = SpecialMessage.parse("w5xyz.40 074"); + assertThat(p).isNotNull(); + assertThat(p.toCall).isEqualTo("W5XYZ"); + assertThat(p.freqKhz).isEqualTo(7074L); + } + + @Test + public void parse_rejectsUnknownBandCode() { + assertThat(SpecialMessage.parse("K1ABC.99 074")).isNull(); + } + + @Test + public void parse_rejectsNonNumericOffset() { + assertThat(SpecialMessage.parse("K1ABC.20 ABC")).isNull(); + } + + @Test + public void parse_rejectsImplausibleCall() { + // No digit in the call field -> not a callsign. + assertThat(SpecialMessage.parse("ABCDE.20 074")).isNull(); + } + + // ---- parse: canned ---- + + @Test + public void parse_recognisesCanned() { + Parsed p = SpecialMessage.parse("K1ABC 73 GL"); + assertThat(p).isNotNull(); + assertThat(p.kind).isEqualTo(Kind.CANNED); + assertThat(p.toCall).isEqualTo("K1ABC"); + assertThat(p.text).isEqualTo("73 GL"); + assertThat(p.summary()).isEqualTo("73 GL"); + } + + @Test + public void parse_rejectsUnknownCannedPayload() { + // "HELLO WORLD" is not a known canned message. + assertThat(SpecialMessage.parse("K1ABC HELLO")).isNull(); + } + + @Test + public void parse_rejectsNullEmptyAndNoSpace() { + assertThat(SpecialMessage.parse(null)).isNull(); + assertThat(SpecialMessage.parse(" ")).isNull(); + assertThat(SpecialMessage.parse("K1ABC")).isNull(); + assertThat(SpecialMessage.parse("K1ABC ")).isNull(); + } + + // ---- round trip ---- + + @Test + public void buildThenParse_roundTripsQsy() { + String msg = SpecialMessage.buildQsyRequest("K1ABC", "15", 74, IaruRegion.REGION_2); + Parsed p = SpecialMessage.parse(msg); + assertThat(p.kind).isEqualTo(Kind.QSY); + assertThat(p.freqKhz).isEqualTo(21074L); + } + + @Test + public void buildThenParse_roundTripsCanned() { + String msg = SpecialMessage.buildCannedMessage("K1ABC", "RR73"); + Parsed p = SpecialMessage.parse(msg); + assertThat(p.kind).isEqualTo(Kind.CANNED); + assertThat(p.text).isEqualTo("RR73"); + } + + // ---- parseFor ---- + + @Test + public void parseFor_returnsMessageAddressedToMe() { + Parsed p = SpecialMessage.parseFor("K1ABC.20 074", "k1abc"); + assertThat(p).isNotNull(); + assertThat(p.kind).isEqualTo(Kind.QSY); + } + + @Test + public void parseFor_ignoresMessageForOtherCall() { + assertThat(SpecialMessage.parseFor("K1ABC.20 074", "W5XYZ")).isNull(); + } + + @Test + public void parseFor_ignoresNonSpecialAndBlankMyCall() { + assertThat(SpecialMessage.parseFor("TNX BOB 73", "K1ABC")).isNull(); + assertThat(SpecialMessage.parseFor("K1ABC.20 074", "")).isNull(); + } + + // ---- isCanned ---- + + @Test + public void isCanned_matchesKnownMessages() { + assertThat(SpecialMessage.isCanned("73")).isTrue(); + assertThat(SpecialMessage.isCanned(" rr73 ")).isTrue(); + assertThat(SpecialMessage.isCanned("not canned")).isFalse(); + assertThat(SpecialMessage.isCanned(null)).isFalse(); + } + + @Test + public void cannedMessages_allFitWithAShortCall() { + // Every canned message must fit alongside a 4-char call in the 13-char frame. + for (String canned : SpecialMessage.CANNED_MESSAGES) { + assertThat(("K1AB " + canned).length()).isAtMost(SpecialMessage.FREE_TEXT_LIMIT); + } + } +} From e62ca6a2f4d5def09785c9e10b0598912b5bedf2 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 21:36:53 -0500 Subject: [PATCH 041/113] Fix Xiegu X6100 S-meter dBm reading above S9 (off by +75 dB) (#634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix Xiegu X6100 S-meter dBm reading above S9 (missing -75 dBm base) The upper branch of X6100Meters.getMeter_dBm (S9..S9+60) dropped the S9 = -75 dBm base offset that the middle branch and the inverse getMeters() mapping both use, so any signal stronger than S9 read 75 dB too high and reported physically impossible positive dBm in the meter panel. Continue the branch from the -75 dBm base so value=120 -> -75 dBm (S9) joins value=242 -> -15 dBm (S9+60) continuously. Add X6100MetersDbmTest (pure JVM) covering every branch; the four upper-branch cases fail before this change. Co-Authored-By: Claude Opus 4.8 (1M context) * Review fixes: make 242 the inclusive S9+60 endpoint getMeter_dBm excluded 242 from the upper branch, so the documented top of the scale fell through to the 0 sentinel — a 60 dB discontinuity at exactly the value the inverse round-trips (getMeters(-15f) == 242). The branch is now value <= 242; only readings above the scale return the sentinel. Tests: 242 asserted as -15 dBm, continuity across the endpoint pinned, the negative-dBm sweep extended to include 242, and the sentinel case moved to 243+. Also corrected the header block, which listed value=0 as -129 dBm when value <= 0 is clamped to the -150 dBm floor. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/x6100/X6100Meters.java | 13 +- .../k1af/ft8af/x6100/X6100MetersDbmTest.java | 123 ++++++++++++++++++ 2 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/x6100/X6100MetersDbmTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/x6100/X6100Meters.java b/ft8af/app/src/main/java/com/k1af/ft8af/x6100/X6100Meters.java index e859401fc..28b6c6664 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/x6100/X6100Meters.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/x6100/X6100Meters.java @@ -53,10 +53,17 @@ public static float getMeter_dBm(float value) { return -150f; } else if (value <= 120f) { return (value * 54f / 120f - 129f); - } else if (value < 242) { - return (value * 60f) / (242f - 120f) - 120f * 60f / (242f - 120f); - + } else if (value <= 242) { + // S9..S9+60: continue up from the S9 = -75 dBm base the middle branch + // (and the inverse getMeters) fix. The base offset was previously + // dropped, reading 75 dB too high (even positive dBm) above S9. + // 242 is inclusive: it is the documented S9+60 endpoint of the scale + // (header comment) and getMeters(-15) returns exactly 242, so excluding + // it made the top of the scale fall through to the 0 sentinel — a 60 dB + // discontinuity at the one value the inverse maps to. + return (value - 120f) * 60f / (242f - 120f) - 75f; } else { + // Above the documented scale: out-of-range marker, not a reading. return 0; } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/x6100/X6100MetersDbmTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/x6100/X6100MetersDbmTest.java new file mode 100644 index 000000000..d8b659bff --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/x6100/X6100MetersDbmTest.java @@ -0,0 +1,123 @@ +package com.k1af.ft8af.x6100; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Pure-JVM coverage for {@link X6100Meters#getMeter_dBm(float)} — the conversion + * from the Xiegu X6100's raw 0–242 S-meter count to a dBm reading shown in the + * meter panel ({@link X6100Meters#toString()} calls it every render). + * + *

The radio pushes the raw count over the network (meter VITA stream, handled + * by {@code X6100Meters.update} → {@code setValues} case 0), so every value here + * is reachable from live radio data. + * + *

The class documents its own S-meter scale (header: {@code 0000=S0, + * 0120=S9, 0242=S9+60dB}) and carries the inverse mapping + * {@link X6100Meters#getMeters(float)}. Both fix the scale as + *

    + *
  • {@code value→0+} → S0 = -129 dBm (the linear branch's intercept), + *
  • {@code value=120} → S9 = -75 dBm, + *
  • {@code value=242} → S9+60 = -15 dBm — inclusive, matching + * {@code getMeters(-15f) == 242}; only values above 242 are + * out-of-scale and return the 0 sentinel. + *
+ * Note that {@code value <= 0} does not evaluate the linear branch at all: it is + * clamped to a -150 dBm floor (no-signal), so the -129 dBm anchor above is the + * limit approached from just inside the branch, not the value returned at 0. + * The upper branch (S9…S9+60) must therefore be continuous with the middle + * branch at S9 = -75 dBm. It previously dropped the -75 dBm base offset, so a + * signal above S9 read 75 dB too high — reporting physically impossible + * positive dBm. These assertions pin the corrected, continuous scale. + * + *

{@code getMeter_dBm} is a pure static method, so no Robolectric runner is + * needed. + */ +public class X6100MetersDbmTest { + + private static final float TOL = 0.1f; + + // ---- lower clamp --------------------------------------------------------- + + @Test + public void zero_isFloorMinus150() { + assertThat(X6100Meters.getMeter_dBm(0f)).isWithin(TOL).of(-150f); + } + + @Test + public void negative_isFloorMinus150() { + assertThat(X6100Meters.getMeter_dBm(-5f)).isWithin(TOL).of(-150f); + } + + // ---- S0..S9 branch (documented anchors) ---------------------------------- + + @Test + public void sZero_isMinus129dBm() { + // value just above 0 approaches S0 = -129 dBm. + assertThat(X6100Meters.getMeter_dBm(1f)).isWithin(0.5f).of(-129f); + } + + @Test + public void sNine_isMinus75dBm() { + // value=120 is S9; middle branch => -75 dBm. + assertThat(X6100Meters.getMeter_dBm(120f)).isWithin(TOL).of(-75f); + } + + // ---- S9..S9+60 branch (the fix) ------------------------------------------ + + @Test + public void justAboveS9_staysContinuousWithMinus75Base() { + // Regression: value=121 must be a hair above S9 (-75 dBm), NOT jump to ~0. + // Correct: (121-120)*60/122 - 75 = -74.51 dBm. + assertThat(X6100Meters.getMeter_dBm(121f)).isWithin(TOL).of(-74.51f); + } + + @Test + public void midUpperBranch_appliesMinus75Base() { + // An S9+~30 signal (value=180), routine on HF. + // Correct: (180-120)*60/122 - 75 = -45.49 dBm (buggy code returned +29.5). + assertThat(X6100Meters.getMeter_dBm(180f)).isWithin(TOL).of(-45.49f); + } + + @Test + public void topOfUpperBranch_isS9Plus60() { + // value=241 approaches S9+60 = -15 dBm. + // Correct: (241-120)*60/122 - 75 = -15.49 dBm. + assertThat(X6100Meters.getMeter_dBm(241f)).isWithin(TOL).of(-15.49f); + } + + @Test + public void upperBranchNeverReturnsPositiveDbm() { + // A receiver S-meter can never legitimately report a positive dBm signal + // on this scale (S9+60 == -15 dBm is the ceiling). Sweep the whole live + // range, 242 included, and assert every reading stays below 0. + for (float v = 1f; v <= 242f; v += 1f) { + assertThat(X6100Meters.getMeter_dBm(v)).isLessThan(0f); + } + } + + // ---- top of scale / upper clamp ----------------------------------------- + + @Test + public void exactly242_isTheS9Plus60Endpoint() { + // 242 is the documented top of the scale, and the inverse mapping returns + // exactly 242 for -15 dBm. It previously fell through to the 0 sentinel, + // putting a 60 dB discontinuity at the one value getMeters round-trips. + assertThat(X6100Meters.getMeter_dBm(242f)).isWithin(TOL).of(-15f); + } + + @Test + public void topOfScaleIsContinuous() { + // No jump between the last in-branch value and the endpoint. + assertThat(X6100Meters.getMeter_dBm(242f) - X6100Meters.getMeter_dBm(241f)) + .isWithin(0.01f).of(60f / 122f); + } + + @Test + public void above242_isZeroSentinel() { + // Only values beyond the documented scale are the out-of-range marker. + assertThat(X6100Meters.getMeter_dBm(243f)).isEqualTo(0f); + assertThat(X6100Meters.getMeter_dBm(300f)).isEqualTo(0f); + } +} From ed4c9252e2948ec489da09147e6abe3faa7eb9d2 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 21:37:11 -0500 Subject: [PATCH 042/113] Fix dropped DXpedition Fox decodes: guard MessageHashMap.addHash against empty/null callsign (#635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Guard MessageHashMap.addHash against empty/null callsign (fixes dropped DXpedition Fox decodes) A DXpedition (Fox/Hound) decode leaves callsignFrom="" but still carries a non-zero call-from hash (derived from the invited callsign, see ft8_decode_jni.cpp). The Ft8Message copy-constructor feeds those into MessageHashMap.addHash for every decoded frame, so the hashCode==0 short-circuit never fires and addHash reached "".charAt(0), throwing StringIndexOutOfBoundsException (or NPE on a null callsign). That exception fired inside FT8SignalListener's per-candidate try/catch, which silently dropped the entire Fox message before it was added to the decode list — so Hound operators never saw the Fox. Guard null/empty at the top of addHash (restoring the intent of the commented-out length check). Behaviour is unchanged for every valid callsign. Adds two regression tests to MessageHashMapTest covering the empty (non-zero hash) and null cases. Co-Authored-By: Claude Opus 4.8 (1M context) * Review fix: drop the stale @return from addHash's Javadoc The method returns void; the doc still advertised a boolean. Replaced it with the actual contract — every non-hashable input is silently ignored and callers can't tell "added" from "skipped". Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/MessageHashMap.java | 21 ++++++++++++---- .../com/k1af/ft8af/MessageHashMapTest.java | 24 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MessageHashMap.java b/ft8af/app/src/main/java/com/k1af/ft8af/MessageHashMap.java index b11b39b8c..6542effc0 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MessageHashMap.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MessageHashMap.java @@ -13,15 +13,28 @@ public class MessageHashMap extends HashMap { private static final String TAG = "MessageHashMap"; /** - * Add a callsign and its hash code to the list + * Add a callsign and its hash code to the list. + * + *

Silently ignores anything that is not a real call to hash: a null/empty + * callsign, the CQ/QRZ/DE tokens, an already-stored hash, a zero hash, or a + * still-unresolved {@code <...>} placeholder. There is no return value — + * callers cannot distinguish "added" from "skipped". * * @param hashCode hash code * @param callsign callsign - * @return false means it already exists */ public synchronized void addHash(long hashCode, String callsign) { - //if (callsign.length()<2){return;} - //if (){return;} + // A null or empty callsign is never a real call to hash. It reaches here + // from the Ft8Message copy-constructor for a DXpedition (Fox/Hound) + // decode, which sets callsignFrom="" while still carrying a non-zero + // call-from hash (derived from the invited callsign) — so the hashCode==0 + // short-circuit below does NOT catch it, and callsign.charAt(0) would + // throw StringIndexOutOfBoundsException (empty) or callsign.equals(...) + // an NPE (null). That exception fired inside the decode loop's try/catch + // and silently dropped the whole Fox message. + if (callsign == null || callsign.isEmpty()) { + return; + } if (callsign.equals("CQ")||callsign.equals("QRZ")||callsign.equals("DE")){ return; } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/MessageHashMapTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/MessageHashMapTest.java index d6bcd7585..7797477bc 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/MessageHashMapTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/MessageHashMapTest.java @@ -72,4 +72,28 @@ public void getCallsign_returnsPlaceholderWhenNoMatch() { map.addHash(0x10L, "K1ABC"); assertThat(map.getCallsign(new long[]{1L, 2L, 3L})).isEqualTo("<...>"); } + + @Test + public void addHash_emptyCallsignWithNonZeroHash_isIgnoredNotCrash() { + // Regression: a DXpedition (Fox/Hound) decode leaves callsignFrom = "" + // yet carries a non-zero call-from hash (derived from the invited call), + // so the zero-hash short-circuit does NOT fire and addHash reached + // "".charAt(0) -> StringIndexOutOfBoundsException. That threw inside the + // decode loop's try/catch, silently dropping the whole Fox message. + MessageHashMap map = new MessageHashMap(); + map.addHash(0xABCDL, ""); + // No crash, and an empty callsign is never stored. + assertThat(map.checkHash(0xABCDL)).isFalse(); + assertThat(map).isEmpty(); + } + + @Test + public void addHash_nullCallsign_isIgnoredNotCrash() { + // Ft8Message.extraInfo/callsign defaults are null; a null callsign must + // not NPE at the reserved-callsign equals() check either. + MessageHashMap map = new MessageHashMap(); + map.addHash(0x1234L, null); + assertThat(map.checkHash(0x1234L)).isFalse(); + assertThat(map).isEmpty(); + } } From 55de7f4ab20d8b3f9b15610e3431ab1b3a3798a6 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 22 Jul 2026 21:37:37 -0500 Subject: [PATCH 043/113] Fix garbled Icom Wi-Fi TX from a shared, concurrently-run audio Runnable (#637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix garbled Icom Wi-Fi TX from a shared, concurrently-run audio Runnable IcomAudioUdp held a single DoTXAudioRunnable instance, overwrote its audioData field on every sendTxAudioData() call, and re-execute()d that same object on a cached thread pool. Each run() loops the full ~12.6 s transmission while reading the shared audioData and mutating the unsynchronised innerSeq. A second TX (or Tune) firing before the first finished ran the same Runnable on two pool threads at once — stomping the PCM buffer mid-transmission and racing the packet sequence number, so the rig keyed but sent duplicated/garbled audio that never decoded. run() also ended with Thread.currentThread().interrupt(), leaving the pooled worker's interrupt flag set for whatever task borrowed that thread next. Root cause: mutable state (the audio buffer + innerSeq) shared across overlapping executions of one reused Runnable, with no guard against concurrent starts. The sibling XieGuAudioUdp was already reworked away from this shared-mutable-Runnable pattern; the Icom path was not. Fix (smallest safe change, keeps the per-TX one-shot model): - Snapshot the PCM into a fresh DoTXAudioRunnable per call (immutable per-transmission buffer) via an extracted floatToPcm16() helper — the conversion is byte-for-byte identical to the old inline code. - Guard concurrent starts with an AtomicBoolean: while a transmission is in flight, an overlapping sendTxAudioData() is dropped instead of racing. The guard is released in run()'s finally so the next cycle always starts, and released too if the pool rejects the submission (shutdown race, via SafeExecutor.tryExecute). - Drop the erroneous Thread.currentThread().interrupt() on the pooled worker. Tests: new IcomAudioUdpTest (pure JVM) covers floatToPcm16 clamping/scaling, the overlap-drop guard (fails without the guard), null-audio no-op, that a dropped call doesn't advance innerSeq, and that the real runnable releases the guard when PTT is off. Co-Authored-By: Claude Opus 4.8 (1M context) * Review fixes: claim the TX guard before converting; clarify the endian helper - sendTxAudioData converted the full ~12.6 s buffer to short[] before testing the overlap guard, so the dropped path — the very case the guard exists for — paid a full allocation + per-sample loop for audio it threw away. The CAS now comes first. If the conversion itself fails we release the guard before rethrowing, so a failure can't latch it and block all future TX. (Not a finally: submitTransmit already releases on a pool rejection, and clearing again afterwards could clear a guard another thread had since claimed.) - convertForTransmit() is an instance seam over floatToPcm16, mirroring submitTransmit, so the tests can pin that a dropped request never converts. - Noted at the call site that shortToBigEndian emits little-endian despite its name, so nobody "fixes" the byte order to match it. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/icom/IcomAudioUdp.java | 160 +++++++++++---- .../com/k1af/ft8af/icom/IcomAudioUdpTest.java | 186 ++++++++++++++++++ 2 files changed, 307 insertions(+), 39 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/icom/IcomAudioUdpTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/icom/IcomAudioUdp.java b/ft8af/app/src/main/java/com/k1af/ft8af/icom/IcomAudioUdp.java index ba7214cf7..7b11d0b24 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/icom/IcomAudioUdp.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/icom/IcomAudioUdp.java @@ -8,85 +8,167 @@ import android.util.Log; import com.k1af.ft8af.GeneralVariables; +import com.k1af.ft8af.concurrent.SafeExecutor; import java.net.DatagramPacket; import java.util.Arrays; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicBoolean; public class IcomAudioUdp extends AudioUdp { private static final String TAG = "IcomAudioUdp"; private final ExecutorService doTXThreadPool =Executors.newCachedThreadPool(); - private final DoTXAudioRunnable doTXAudioRunnable=new DoTXAudioRunnable(this); + + /** + * Guards against overlapping transmissions. Each {@link #sendTxAudioData} call + * used to mutate the {@code audioData} field of a single shared + * {@link DoTXAudioRunnable} and re-{@code execute()} that same instance on the + * cached pool. A second TX (or Tune) firing before the first ~12.6 s run + * finished then ran the same Runnable on two pool threads at once — + * stomping the shared PCM buffer mid-transmission and racing the unsynchronised + * {@code innerSeq} increment, producing duplicated/garbled Icom-over-Wi-Fi audio + * and a desynced packet sequence. We now snapshot the PCM per call and refuse a + * new submission while one is in flight. + * + *

Package-private so unit tests can observe/reset the guard. + */ + final AtomicBoolean transmitting = new AtomicBoolean(false); @Override public void sendTxAudioData(float[] audioData) { if (audioData==null) return; - short[] temp=new short[audioData.length]; + // Claim the transmit slot BEFORE converting. The conversion allocates a + // short[] the length of a full ~12.6 s FT8 buffer and walks every sample; + // doing it first meant an overlapping TX — the exact case this guard exists + // for — paid that cost only to throw the result away. + if (!transmitting.compareAndSet(false, true)) { + // A transmission is already running on the pool. Starting another here + // would run a concurrent DoTXAudioRunnable that interleaves PCM on the + // shared socket and races innerSeq. Drop the overlapping request. + Log.w(TAG, "sendTxAudioData: transmit already in progress, dropping overlapping request"); + return; + } + //Incoming audio is LPCM, 32-bit float, 12000Hz //iCOM audio format is LPCM 16-bit Int, 12000Hz //Need to convert from float to 16-bit int + short[] temp; + try { + temp = convertForTransmit(audioData); + } catch (RuntimeException | Error e) { + // We hold the guard and never submitted, so release it here — a latched + // guard would block every future transmission for the process lifetime. + // (Deliberately not a finally around submitTransmit: that already + // releases on a pool rejection, and re-clearing afterwards could clear a + // guard a different thread had since claimed.) + transmitting.set(false); + throw e; + } + submitTransmit(temp); + } + + /** + * Instance seam over {@link #floatToPcm16} so tests can observe whether the + * conversion ran at all (an overlapping request must be dropped before paying + * for it) — same pattern as {@link #submitTransmit}. + */ + short[] convertForTransmit(float[] audioData) { + return floatToPcm16(audioData); + } + + /** + * Convert incoming LPCM 32-bit float samples ([-1.0, 1.0]) to LPCM 16-bit int. + * Out-of-range samples are clamped before scaling, matching the original + * inline conversion exactly. + */ + static short[] floatToPcm16(float[] audioData) { + short[] temp = new short[audioData.length]; for (int i = 0; i < audioData.length; i++) { float x = audioData[i]; if (x > 1.0) x = 1.0f; else if (x < -1.0) x = -1.0f; - temp[i] = (short) (x * 32767.0); + temp[i] = (short) (x * 32767.0); + } + return temp; + } + + /** + * Submit a fresh {@link DoTXAudioRunnable} for {@code pcm}. Each call gets its + * own runnable holding an immutable snapshot, so there is no shared mutable + * state between transmissions. If the pool rejects the task (shutdown race) the + * transmit guard is released so a later TX can still start. + * + *

Package-private and overridable so unit tests can observe submissions + * without a live socket. + */ + void submitTransmit(short[] pcm) { + if (!SafeExecutor.tryExecute(doTXThreadPool, new DoTXAudioRunnable(this, pcm))) { + transmitting.set(false); } - doTXAudioRunnable.audioData=temp; - doTXThreadPool.execute(doTXAudioRunnable); } + private static class DoTXAudioRunnable implements Runnable{ - IcomAudioUdp icomAudioUdp; - short[] audioData;//Incoming audio is LPCM 16-bit Int, 12000Hz + final IcomAudioUdp icomAudioUdp; + final short[] audioData;//Incoming audio is LPCM 16-bit Int, 12000Hz - public DoTXAudioRunnable(IcomAudioUdp icomAudioUdp) { + public DoTXAudioRunnable(IcomAudioUdp icomAudioUdp, short[] audioData) { this.icomAudioUdp = icomAudioUdp; + this.audioData = audioData; } @Override public void run() { - if (audioData==null) return; - - final int partialLen = IComPacketTypes.TX_BUFFER_SIZE * 2;//Packet data length - //Convert to BYTE, little-endian - - //byte[] data = new byte[audioData.length * 2 + partialLen * 4];//Extra silence padding: 20ms*2 = 80ms total before and after - //Play silence first before audio; the for-i loop handles leading silence, the for-j loop handles trailing silence - byte[] audioPacket = new byte[partialLen]; - for (int i = 0; i < (audioData.length / IComPacketTypes.TX_BUFFER_SIZE) + 8; i++) {//6 extra cycles: 3 before, 3 after - if (!icomAudioUdp.isPttOn) break; - long now = System.currentTimeMillis() - 1;//Get current time - - icomAudioUdp.sendTrackedPacket(IComPacketTypes.AudioPacket.getTxAudioPacket(audioPacket - , (short) 0, icomAudioUdp.localId, icomAudioUdp.remoteId, icomAudioUdp.innerSeq)); - icomAudioUdp.innerSeq++; - - Arrays.fill(audioPacket,(byte)0x00); - if (i>=3) {//Let the first two empty packets be sent out - for (int j = 0; j < IComPacketTypes.TX_BUFFER_SIZE; j++) { - if ((i-3) * IComPacketTypes.TX_BUFFER_SIZE + j < audioData.length) { - System.arraycopy(IComPacketTypes.shortToBigEndian((short) - (audioData[(i-3) * IComPacketTypes.TX_BUFFER_SIZE + j] - * GeneralVariables.volumePercent))//Multiply by volume ratio - , 0, audioPacket, j * 2, 2); + try { + if (audioData == null) return; + + final int partialLen = IComPacketTypes.TX_BUFFER_SIZE * 2;//Packet data length + // Samples go out little-endian (low byte first), which is what the + // helper below emits despite being named shortToBigEndian — see its + // Javadoc in IComPacketTypes. Don't "fix" the byte order to match the + // name; the wire format is little-endian. + + //byte[] data = new byte[audioData.length * 2 + partialLen * 4];//Extra silence padding: 20ms*2 = 80ms total before and after + //Play silence first before audio; the for-i loop handles leading silence, the for-j loop handles trailing silence + byte[] audioPacket = new byte[partialLen]; + for (int i = 0; i < (audioData.length / IComPacketTypes.TX_BUFFER_SIZE) + 8; i++) {//6 extra cycles: 3 before, 3 after + if (!icomAudioUdp.isPttOn) break; + long now = System.currentTimeMillis() - 1;//Get current time + + icomAudioUdp.sendTrackedPacket(IComPacketTypes.AudioPacket.getTxAudioPacket(audioPacket + , (short) 0, icomAudioUdp.localId, icomAudioUdp.remoteId, icomAudioUdp.innerSeq)); + icomAudioUdp.innerSeq++; + + Arrays.fill(audioPacket, (byte) 0x00); + if (i >= 3) {//Let the first two empty packets be sent out + for (int j = 0; j < IComPacketTypes.TX_BUFFER_SIZE; j++) { + if ((i - 3) * IComPacketTypes.TX_BUFFER_SIZE + j < audioData.length) { + System.arraycopy(IComPacketTypes.shortToBigEndian((short) + (audioData[(i - 3) * IComPacketTypes.TX_BUFFER_SIZE + j] + * GeneralVariables.volumePercent))//Multiply by volume ratio + , 0, audioPacket, j * 2, 2); + } } } - } - while (icomAudioUdp.isPttOn) { - if (System.currentTimeMillis() - now >= 21) {//20ms per cycle - break; + while (icomAudioUdp.isPttOn) { + if (System.currentTimeMillis() - now >= 21) {//20ms per cycle + break; + } } } + Log.d(TAG, "run: Audio transmission complete!!"); + } finally { + // Always release the guard, even if PTT dropped early or a send threw, + // so the next TX cycle can start. (Previously the shared runnable also + // wrongly left the pooled worker's interrupt flag set here.) + icomAudioUdp.transmitting.set(false); } - Log.d(TAG, "run: Audio transmission complete!!" ); - Thread.currentThread().interrupt(); } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/icom/IcomAudioUdpTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/icom/IcomAudioUdpTest.java new file mode 100644 index 000000000..3b5b5f53b --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/icom/IcomAudioUdpTest.java @@ -0,0 +1,186 @@ +package com.k1af.ft8af.icom; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +/** + * Regression coverage for {@link IcomAudioUdp}'s TX-audio path. + * + *

The old code held a single shared {@code DoTXAudioRunnable}, mutated its + * {@code audioData} field on every {@link IcomAudioUdp#sendTxAudioData} call, and + * re-{@code execute()}d that same instance on a cached thread pool. A second TX (or + * Tune) firing before the first ~12.6 s run finished ran the same + * Runnable on two pool threads concurrently — stomping the shared PCM buffer and + * racing the unsynchronised {@code innerSeq}, garbling Icom-over-Wi-Fi audio. It also + * left the pooled worker's interrupt flag set on exit. + * + *

The fix snapshots PCM per call into a fresh runnable and guards with an + * {@code AtomicBoolean} so an overlapping submission is dropped instead of racing. + * These tests drive the {@code submitTransmit} seam and the guard directly — no + * socket, plain JUnit + Truth (the only Android type touched is {@code Log}, stubbed + * via {@code returnDefaultValues}). + */ +public class IcomAudioUdpTest { + + /** Captures submissions instead of running them, so the guard stays "in flight". */ + private static class RecordingIcomAudioUdp extends IcomAudioUdp { + final List submitted = new ArrayList<>(); + + @Override + void submitTransmit(short[] pcm) { + submitted.add(pcm); + } + } + + /** + * Adds a count of how many times the PCM conversion actually ran, so the tests + * can pin that an overlapping (dropped) request never pays for it, and can force + * the conversion to fail. + */ + private static class CountingIcomAudioUdp extends RecordingIcomAudioUdp { + int conversions = 0; + Error conversionFailure = null; + + @Override + short[] convertForTransmit(float[] audioData) { + conversions++; + if (conversionFailure != null) { + throw conversionFailure; + } + return super.convertForTransmit(audioData); + } + } + + // ---- floatToPcm16 conversion ---- + + @Test + public void floatToPcm16_clampsAndScales() { + short[] out = IcomAudioUdp.floatToPcm16(new float[] {0f, 1.0f, -1.0f, 0.5f, 2.0f, -3.0f}); + + assertThat(out.length).isEqualTo(6); + assertThat(out[0]).isEqualTo((short) 0); + assertThat(out[1]).isEqualTo((short) 32767); // +full scale + assertThat(out[2]).isEqualTo((short) -32767); // -full scale (clamped to -1.0) + assertThat(out[3]).isEqualTo((short) 16383); // 0.5 * 32767 truncated + assertThat(out[4]).isEqualTo((short) 32767); // >1.0 clamped, not overflowed + assertThat(out[5]).isEqualTo((short) -32767); // <-1.0 clamped, not overflowed + } + + @Test + public void floatToPcm16_emptyInput_returnsEmpty() { + assertThat(IcomAudioUdp.floatToPcm16(new float[0])).hasLength(0); + } + + // ---- overlap guard ---- + + @Test + public void sendTxAudioData_dropsOverlappingRequestWhileTransmitting() { + RecordingIcomAudioUdp rig = new RecordingIcomAudioUdp(); + + rig.sendTxAudioData(new float[10]); + // First call acquired the guard and submitted exactly one transmission. + assertThat(rig.submitted).hasSize(1); + assertThat(rig.transmitting.get()).isTrue(); + assertThat(rig.submitted.get(0)).hasLength(10); + + // A second call while the first is still "in flight" must be dropped — not + // submitted as a concurrent runnable that would garble the shared stream. + rig.sendTxAudioData(new float[10]); + assertThat(rig.submitted).hasSize(1); + + // Once the running transmission releases the guard, the next TX starts again. + rig.transmitting.set(false); + rig.sendTxAudioData(new float[10]); + assertThat(rig.submitted).hasSize(2); + } + + @Test + public void sendTxAudioData_droppedRequestSkipsTheConversionEntirely() { + // The guard is claimed before the PCM conversion, so an overlapping request + // costs nothing: no full-buffer short[] allocation and no per-sample loop for + // audio that is about to be thrown away. + CountingIcomAudioUdp rig = new CountingIcomAudioUdp(); + + rig.sendTxAudioData(new float[10]); // claims the guard, converts once + assertThat(rig.conversions).isEqualTo(1); + assertThat(rig.transmitting.get()).isTrue(); + + rig.sendTxAudioData(new float[10]); // overlapping: dropped before converting + assertThat(rig.submitted).hasSize(1); + assertThat(rig.conversions).isEqualTo(1); + + // ...and once the slot frees up, the next TX converts again as normal. + rig.transmitting.set(false); + rig.sendTxAudioData(new float[10]); + assertThat(rig.conversions).isEqualTo(2); + } + + @Test + public void sendTxAudioData_conversionFailureReleasesTheGuard() { + // If the conversion blows up (OOM on the ~12.6 s buffer is the realistic + // case) we still hold the guard and never submitted. Leaving it latched + // would block every future transmission for the life of the process. + CountingIcomAudioUdp rig = new CountingIcomAudioUdp(); + rig.conversionFailure = new OutOfMemoryError("simulated"); + + try { + rig.sendTxAudioData(new float[10]); + throw new AssertionError("expected the conversion failure to propagate"); + } catch (OutOfMemoryError expected) { + // propagated to the caller, as before + } + + assertThat(rig.submitted).isEmpty(); + assertThat(rig.transmitting.get()).isFalse(); + + // A later TX still works. + rig.conversionFailure = null; + rig.sendTxAudioData(new float[10]); + assertThat(rig.submitted).hasSize(1); + } + + @Test + public void sendTxAudioData_nullAudio_isNoOpAndLeavesGuardFree() { + RecordingIcomAudioUdp rig = new RecordingIcomAudioUdp(); + + rig.sendTxAudioData(null); + + assertThat(rig.submitted).isEmpty(); + assertThat(rig.transmitting.get()).isFalse(); + } + + @Test + public void sendTxAudioData_dropDoesNotAdvanceSequence() { + RecordingIcomAudioUdp rig = new RecordingIcomAudioUdp(); + short seqBefore = rig.innerSeq; + + rig.sendTxAudioData(new float[10]); // acquires guard, no packets sent by the stub + rig.sendTxAudioData(new float[10]); // dropped + + // Neither the acquired-but-stubbed submit nor the dropped call touches innerSeq. + assertThat(rig.innerSeq).isEqualTo(seqBefore); + } + + // ---- guard is released when the real transmission finishes ---- + + @Test + public void realRun_releasesGuardWhenPttOff() throws Exception { + // Use the real submitTransmit + cached pool. With PTT off, the runnable's + // for-loop breaks before any sendTrackedPacket (so no socket is needed) and + // the finally block must clear the guard so a later TX can start. + IcomAudioUdp rig = new IcomAudioUdp(); + rig.isPttOn = false; + + rig.sendTxAudioData(new float[240]); + + long deadline = System.currentTimeMillis() + 2000; + while (rig.transmitting.get() && System.currentTimeMillis() < deadline) { + Thread.sleep(5); + } + assertThat(rig.transmitting.get()).isFalse(); + } +} From 7f38704bc99ce66bc7235658e48a7ed7368b0a89 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 07:38:46 -0500 Subject: [PATCH 044/113] Fix TX-500 SWR meter crash on garbled RM reply (unguarded parseInt) (#643) Yaesu3Command.getTX500MeterValue parsed the four value chars of a Lab599 Discovery TX-500 RM SWR reply with a raw Integer.parseInt, while every other meter getter in the class routes through parseMeterValue(), which catches NumberFormatException and returns 0. That guard exists precisely because meter replies are decoded on the serial/Bluetooth read thread (KenwoodTS2000Rig.onReceiveData, reached from BaseRig's receive callback) with no surrounding try/catch, so a non-numeric value must not throw. isTX500MeterSWR only checks length>=5 and index 0 == '1'; it never verifies chars 1-4 are digits. A garbled or partial frame (a noise byte, or a ';' terminator sliding into substring(1,5)) therefore passed the gate and crashed the read thread with NumberFormatException on every meter poll while transmitting on a TX-500. Route getTX500MeterValue through the existing parseMeterValue guard. Added two fail-before cases to Yaesu3CommandTest (noise byte and in-window terminator) asserting it returns 0 instead of throwing. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/rigs/Yaesu3Command.java | 10 ++++++++-- .../com/k1af/ft8af/rigs/Yaesu3CommandTest.java | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu3Command.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu3Command.java index 1d7e06383..12d8a7f97 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu3Command.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu3Command.java @@ -172,11 +172,17 @@ public static boolean isTX500MeterSWR(Yaesu3Command command) { * ratio; see {@link DiscoveryTX500Rig#tx500CatToSwrRatio(int)}. * * @param command RM command in {@code RM1vvvv} form - * @return the raw 0-30 meter value, or 0 when the reply is too short + * @return the raw 0-30 meter value, or 0 when the reply is too short or the + * value chars are non-numeric (garbled frame) */ public static int getTX500MeterValue(Yaesu3Command command) { if (command.data.length() < 5) return 0; - return Integer.parseInt(command.data.substring(1, 5)); + // Route through parseMeterValue like every other meter getter: a garbled + // or partial RM frame whose value chars aren't all digits (e.g. a ';' + // terminator or noise byte landing in substring(1,5)) must return 0 + // rather than throw NumberFormatException — onReceiveData runs on the + // serial/Bluetooth read thread with no surrounding try/catch. + return parseMeterValue(command.data.substring(1, 5)); } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu3CommandTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu3CommandTest.java index 691fb5169..4bb20523a 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu3CommandTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu3CommandTest.java @@ -153,4 +153,20 @@ public void tx500_shortDataDefaults() { assertThat(Yaesu3Command.isTX500MeterSWR(c)).isFalse(); assertThat(Yaesu3Command.getTX500MeterValue(c)).isEqualTo(0); } + + @Test + public void tx500_garbledValueReturnsZeroNotThrow() { + // isTX500MeterSWR only gates on length>=5 and index0=='1'; it does NOT + // verify chars 1..4 are digits. A garbled/partial RM frame (noise byte, + // or a ';' terminator sliding into substring(1,5)) reaches + // getTX500MeterValue and previously crashed with NumberFormatException on + // the read thread. It must now return 0, like every other meter getter. + Yaesu3Command noise = Yaesu3Command.getCommand("RM1x023"); // data "1x023" + assertThat(Yaesu3Command.isTX500MeterSWR(noise)).isTrue(); + assertThat(Yaesu3Command.getTX500MeterValue(noise)).isEqualTo(0); + + Yaesu3Command terminator = Yaesu3Command.getCommand("RM1030;"); // data "1030;" + assertThat(Yaesu3Command.isTX500MeterSWR(terminator)).isTrue(); + assertThat(Yaesu3Command.getTX500MeterValue(terminator)).isEqualTo(0); + } } From 7d266d6193f29940e0d8294facc438958a786fee Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 08:12:37 -0500 Subject: [PATCH 045/113] Fix web-logbook ADIF export using UTF-16 char length instead of UTF-8 byte length (#642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix web-logbook ADIF export declaring UTF-16 char length instead of UTF-8 byte length DatabaseOpr.downQSLTable — the ADIF generator behind the built-in web logbook download (LogHttpServer) — declared every length with Java String.length() (a UTF-16 char count) instead of the UTF-8 byte count the ADIF grammar requires. For any non-ASCII field (an accented comment, an international POTA park name), the declared length is short, so a byte-correct consumer (LoTW/QRZ/Cloudlog/WSJT-X) reads fewer bytes than were written, truncating the field and mis-aligning the record boundary — the record is rejected or mangled. This is the un-migrated twin of the canonical writer: AdifRecord.build() and the ThirdPartyService upload paths already route every length through AdifFormat.utf8Length() (whose javadoc documents exactly this defect). Route downQSLTable and its appendPotaField helper through the same shared helper. utf8Length has an ASCII fast-path, so pure-ASCII rows stay byte-identical and there is no measurable cost. Also null-guard the comment field (mirroring AdifRecord.build's `comment == null ? ""`): the old comment.length() dereference NPE'd on a NULL comment, aborting the entire web-logbook download and leaking the cursor. Added DatabaseOprAdifLengthTest (Robolectric + MatrixCursor, mirroring DatabaseOprAdifModeTest): non-ASCII comment/operator/POTA sig_info each declare the UTF-8 byte length, ASCII stays char-length (byte-identical), and a NULL comment no longer throws. 4 of the 5 cases fail before the fix. Co-Authored-By: Claude Opus 4.8 (1M context) * Format downQSLTable ADIF fields with Locale.US to keep headers ASCII Copilot review: %d under a default locale with Arabic-Indic numerals emitted non-ASCII digits in the length headers. Align with AdifRecord.build()'s Locale.US usage; drop the now-unneeded DefaultLocale lint suppression. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/database/DatabaseOpr.java | 68 ++++----- .../database/DatabaseOprAdifLengthTest.java | 129 ++++++++++++++++++ 2 files changed, 165 insertions(+), 32 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprAdifLengthTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 911afdfb0..003878bc0 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -51,6 +51,7 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; +import java.util.Locale; public class DatabaseOpr extends SQLiteOpenHelper { private static final String TAG = "DatabaseOpr"; @@ -1198,7 +1199,7 @@ public void getCallsignQTH(String callsign) { * @param isSWL whether in SWL mode * @return ADIF text content */ - @SuppressLint({"Range", "DefaultLocale"}) + @SuppressLint("Range") public String downQSLTable(Cursor cursor, boolean isSWL) { StringBuilder logStr = new StringBuilder(); @@ -1223,8 +1224,8 @@ public String downQSLTable(Cursor cursor, boolean isSWL) { } if (cursor.getString(cursor.getColumnIndex("gridsquare")) != null) { - logStr.append(String.format("%s " - , cursor.getString(cursor.getColumnIndex("gridsquare")).length() + logStr.append(String.format(Locale.US, "%s " + , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("gridsquare"))) , cursor.getString(cursor.getColumnIndex("gridsquare")))); } @@ -1234,78 +1235,78 @@ public String downQSLTable(Cursor cursor, boolean isSWL) { String mode = cursor.getString(cursor.getColumnIndex("mode")); String submode = AdifFormat.mfskSubmode(mode); if (submode != null) { - logStr.append(String.format("MFSK %s " - , submode.length(), submode)); + logStr.append(String.format(Locale.US, "MFSK %s " + , AdifFormat.utf8Length(submode), submode)); } else { - logStr.append(String.format("%s " - , mode.length(), mode)); + logStr.append(String.format(Locale.US, "%s " + , AdifFormat.utf8Length(mode), mode)); } } if (cursor.getString(cursor.getColumnIndex("rst_sent")) != null) { - logStr.append(String.format("%s " - , cursor.getString(cursor.getColumnIndex("rst_sent")).length() + logStr.append(String.format(Locale.US, "%s " + , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("rst_sent"))) , cursor.getString(cursor.getColumnIndex("rst_sent")))); } if (cursor.getString(cursor.getColumnIndex("rst_rcvd")) != null) { - logStr.append(String.format("%s " - , cursor.getString(cursor.getColumnIndex("rst_rcvd")).length() + logStr.append(String.format(Locale.US, "%s " + , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("rst_rcvd"))) , cursor.getString(cursor.getColumnIndex("rst_rcvd")))); } if (cursor.getString(cursor.getColumnIndex("qso_date")) != null) { - logStr.append(String.format("%s " - , cursor.getString(cursor.getColumnIndex("qso_date")).length() + logStr.append(String.format(Locale.US, "%s " + , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("qso_date"))) , cursor.getString(cursor.getColumnIndex("qso_date")))); } if (cursor.getString(cursor.getColumnIndex("time_on")) != null) { - logStr.append(String.format("%s " - , cursor.getString(cursor.getColumnIndex("time_on")).length() + logStr.append(String.format(Locale.US, "%s " + , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("time_on"))) , cursor.getString(cursor.getColumnIndex("time_on")))); } if (cursor.getString(cursor.getColumnIndex("qso_date_off")) != null) { - logStr.append(String.format("%s " - , cursor.getString(cursor.getColumnIndex("qso_date_off")).length() + logStr.append(String.format(Locale.US, "%s " + , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("qso_date_off"))) , cursor.getString(cursor.getColumnIndex("qso_date_off")))); } if (cursor.getString(cursor.getColumnIndex("time_off")) != null) { - logStr.append(String.format("%s " - , cursor.getString(cursor.getColumnIndex("time_off")).length() + logStr.append(String.format(Locale.US, "%s " + , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("time_off"))) , cursor.getString(cursor.getColumnIndex("time_off")))); } if (cursor.getString(cursor.getColumnIndex("band")) != null) { - logStr.append(String.format("%s " - , cursor.getString(cursor.getColumnIndex("band")).length() + logStr.append(String.format(Locale.US, "%s " + , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("band"))) , cursor.getString(cursor.getColumnIndex("band")))); } if (cursor.getString(cursor.getColumnIndex("freq")) != null) { - logStr.append(String.format("%s " - , cursor.getString(cursor.getColumnIndex("freq")).length() + logStr.append(String.format(Locale.US, "%s " + , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("freq"))) , cursor.getString(cursor.getColumnIndex("freq")))); } if (cursor.getString(cursor.getColumnIndex("station_callsign")) != null) { - logStr.append(String.format("%s " - , cursor.getString(cursor.getColumnIndex("station_callsign")).length() + logStr.append(String.format(Locale.US, "%s " + , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("station_callsign"))) , cursor.getString(cursor.getColumnIndex("station_callsign")))); } if (cursor.getString(cursor.getColumnIndex("my_gridsquare")) != null) { - logStr.append(String.format("%s " - , cursor.getString(cursor.getColumnIndex("my_gridsquare")).length() + logStr.append(String.format(Locale.US, "%s " + , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("my_gridsquare"))) , cursor.getString(cursor.getColumnIndex("my_gridsquare")))); } if (cursor.getColumnIndex("operator") != -1) { if (cursor.getString(cursor.getColumnIndex("operator")) != null) { - logStr.append(String.format("%s " - , cursor.getString(cursor.getColumnIndex("operator")).length() + logStr.append(String.format(Locale.US, "%s " + , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("operator"))) , cursor.getString(cursor.getColumnIndex("operator")))); } } @@ -1318,11 +1319,14 @@ public String downQSLTable(Cursor cursor, boolean isSWL) { appendPotaField(logStr, cursor, "sig_info", "SIG_INFO"); String comment = cursor.getString(cursor.getColumnIndex("comment")); + if (comment == null) { + comment = ""; + } //Distance: 99 km //When writing to db, must append " km" - logStr.append(String.format("%s \n" - , comment.length() + logStr.append(String.format(Locale.US, "%s \n" + , AdifFormat.utf8Length(comment) , comment)); } @@ -1336,7 +1340,7 @@ private static void appendPotaField(StringBuilder sb, Cursor cursor, String colu if (idx < 0) return; String value = cursor.getString(idx); if (value == null || value.isEmpty()) return; - sb.append(String.format("<%s:%d>%s ", adifName, value.length(), value)); + sb.append(String.format(Locale.US, "<%s:%d>%s ", adifName, AdifFormat.utf8Length(value), value)); } /** diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprAdifLengthTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprAdifLengthTest.java new file mode 100644 index 000000000..485ad47f0 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprAdifLengthTest.java @@ -0,0 +1,129 @@ +package com.k1af.ft8af.database; + +import static com.google.common.truth.Truth.assertThat; + +import android.database.MatrixCursor; + +import androidx.test.core.app.ApplicationProvider; + +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** + * Verifies that the web-logbook ADIF export in {@link DatabaseOpr#downQSLTable} declares every + * field length as the UTF-8 byte count (what the ADIF {@code } grammar + * requires), not the UTF-16 {@link String#length()} char count. The two differ for any non-ASCII + * content (an accented comment, an international POTA park name): declaring the shorter char count + * makes a byte-correct consumer (LoTW/QRZ/Cloudlog) read fewer bytes than were written, truncating + * the field and mis-aligning the record boundary. The canonical writer {@link + * com.k1af.ft8af.log.AdifRecord} already uses {@code AdifFormat.utf8Length}; this covers the + * un-migrated {@code downQSLTable} twin used by the built-in web logbook download. + */ +@RunWith(RobolectricTestRunner.class) +public class DatabaseOprAdifLengthTest { + + /** Columns downQSLTable reads, including the optional operator/POTA columns. */ + private static final String[] COLUMNS = { + "call", "isLotW_QSL", "isQSL", "gridsquare", "mode", "rst_sent", "rst_rcvd", + "qso_date", "time_on", "qso_date_off", "time_off", "band", "freq", + "station_callsign", "my_gridsquare", "operator", + "my_sig", "my_sig_info", "sig", "sig_info", "comment" + }; + + private DatabaseOpr opr; + + @Before + public void setUp() { + opr = new DatabaseOpr(ApplicationProvider.getApplicationContext(), null, null, 19); + } + + @After + public void tearDown() { + opr.close(); + } + + /** Export one row with the given comment / operator / sig_info values (nulls allowed). */ + private String export(String comment, String operator, String sigInfo) { + MatrixCursor cursor = new MatrixCursor(COLUMNS); + cursor.addRow(new Object[]{ + "W1AW", 0, 0, null, "FT8", "+00", "-05", + "20260101", "1200", null, null, "20m", "14074000", + null, null, operator, + null, null, null, sigInfo, comment + }); + return opr.downQSLTable(cursor, false); + } + + /** Assert the declared length of {@code value} equals value's UTF-8 byte length. */ + private static void assertDeclaredLenIsUtf8ByteLen(String adif, String name, String value) { + Matcher m = Pattern.compile("<" + name + ":(\\d+)>").matcher(adif); + assertThat(m.find()).isTrue(); + int declared = Integer.parseInt(m.group(1)); + assertThat(declared).isEqualTo(value.getBytes(StandardCharsets.UTF_8).length); + } + + @Test + public void comment_nonAscii_declaresUtf8ByteLength() { + // "Café QSO": 8 UTF-16 chars, 9 UTF-8 bytes (é = 2 bytes). Old code emitted , + // truncating the trailing 'O' and corrupting the boundary. + String comment = "Café QSO"; + String adif = export(comment, null, null); + assertDeclaredLenIsUtf8ByteLen(adif, "comment", comment); + assertThat(adif).contains("Café QSO "); + } + + @Test + public void operator_nonAscii_declaresUtf8ByteLength() { + String op = "JÜRGEN"; // Ü = 2 UTF-8 bytes -> 6 chars, 7 bytes + String adif = export("QSO by FT8AF", op, null); + assertDeclaredLenIsUtf8ByteLen(adif, "operator", op); + } + + @Test + public void potaSigInfo_nonAscii_declaresUtf8ByteLength() { + String park = "Åland-01"; // Å = 2 UTF-8 bytes + String adif = export("QSO by FT8AF", null, park); + assertDeclaredLenIsUtf8ByteLen(adif, "SIG_INFO", park); + } + + @Test + public void asciiComment_unchanged_declaresCharLength() { + // Pure-ASCII path must stay byte-identical to the prior format. + String comment = "QSO by FT8AF"; + String adif = export(comment, null, null); + assertThat(adif).contains("" + comment + " "); + } + + @Test + public void nullComment_doesNotThrow_emitsEmptyField() { + // Previously comment.length() NPE'd on a NULL comment, aborting the whole download. + String adif = export(null, null, null); + assertThat(adif).contains(" "); + } + + @Test + public void arabicDefaultLocale_stillEmitsAsciiDigitLengths() { + // %d under a default locale whose numbering system is Arabic-Indic (e.g. "ar") + // emits ٠١٢٣… digits, making every header unparseable to ADIF + // consumers. The export must format with Locale.US regardless of device locale. + Locale saved = Locale.getDefault(); + Locale.setDefault(new Locale("ar")); + try { + String adif = export("Café QSO", "JÜRGEN", null); + assertThat(adif).contains("Café QSO "); + assertDeclaredLenIsUtf8ByteLen(adif, "operator", "JÜRGEN"); + // No non-ASCII digits anywhere in the export. + assertThat(adif.matches("(?s).*[\\u0660-\\u0669].*")).isFalse(); + } finally { + Locale.setDefault(saved); + } + } +} From ad467f12e239d22d16ab355aeb1a1a7c0f549a60 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 08:12:41 -0500 Subject: [PATCH 046/113] Reject non-Maidenhead tokens in gridToLatLng/gridToPolygon (bogus pins, wrong distances, NPE) (#644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Reject non-Maidenhead tokens in gridToLatLng/gridToPolygon (bogus pins/distances + NPE) The grid decoders MaidenheadGrid.gridToLatLng and gridToPolygon only gated on string length (2/4/6), never on the Maidenhead alphabet. Any right-length token — an imported ADIF GRIDSQUARE of "1234", a garbled report, a callsign-map entry — was decoded into coordinates: the field-letter subtraction byte-'A' silently went negative for a digit, producing an off-planet lat/lng that Play-Services LatLng then normalised into a plausible-but-wrong point. That plotted a bogus map pin and polluted the distance statistics, even though call sites such as GridOsmMapView explicitly rely on a null return to *validate* the locator ("Validate if it is a valid grid locator"). gridToPolygon additionally had no null guard (its sibling gridToLatLng does), so a null gridsquare reaching the GridPolygon overlay NPE'd on grid.length() (swallowed by addGridPolygon's catch, but still wrong). Route both decoders through a shared isDecodableGrid() gate that checks non-null, supported length, the RR/RR73 sign-off collision, and — new — the per-position Maidenhead alphabet (field A-R, square 0-9, subsquare A-X, case-insensitive), matching the grammar already documented in GridOsmMapView and checkMaidenhead. Valid locators of every length and case decode byte-identically; only non-locators are now rejected. Added MaidenheadGridTest coverage: right-length wrong-alphabet tokens return null for both decoders, gridToPolygon(null) returns null instead of throwing, and valid grids still decode. Co-Authored-By: Claude Opus 4.8 (1M context) * Normalize grid case with Locale.ROOT in gridToLatLng/gridToPolygon Copilot review: the default-locale toUpperCase() calls break under Turkish-style locales ('i' -> 'İ', multi-byte, shifts every getBytes() index). Normalize once after isDecodableGrid passes so the later calls are locale-safe no-ops; add a Turkish-default-locale regression test. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../k1af/ft8af/maidenhead/MaidenheadGrid.java | 72 ++++++++++++++++--- .../ft8af/maidenhead/MaidenheadGridTest.java | 67 +++++++++++++++++ 2 files changed, 128 insertions(+), 11 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/maidenhead/MaidenheadGrid.java b/ft8af/app/src/main/java/com/k1af/ft8af/maidenhead/MaidenheadGrid.java index 0cb787067..67c520527 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/maidenhead/MaidenheadGrid.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/maidenhead/MaidenheadGrid.java @@ -15,6 +15,7 @@ import com.google.android.gms.maps.model.LatLng; import java.util.List; +import java.util.Locale; public class MaidenheadGrid { private static final String TAG = "MaidenheadGrid"; @@ -30,6 +31,57 @@ public class MaidenheadGrid { */ private static final float SUBSQUARES = 24f; + /** + * Gate shared by {@link #gridToLatLng} and {@link #gridToPolygon}: a token is only decoded + * into coordinates when it is a genuine Maidenhead locator. It must be non-null, of a + * supported length (2/4/6), not the {@code RR}/{@code RR73} sign-off greeting (which collides + * with the locator parser), and use the Maidenhead alphabet at every position — see + * {@link #hasValidGridChars}. + * + *

Without the alphabet check both decoders happily converted any right-length string + * (an ADIF {@code GRIDSQUARE} of "1234", a garbled report) into an arbitrary LatLng: the + * field-letter subtraction {@code byte - 'A'} silently went negative for a digit, producing + * an off-planet coordinate that Play-Services {@code LatLng} then normalised into a plausible + * but wrong point — plotting a bogus map pin and polluting the distance statistics. Callers + * such as {@code GridOsmMapView} explicitly rely on a {@code null} return to validate + * the locator (see its "Validate if it is a valid grid locator" call site), so returning a + * coordinate for a non-locator defeated that contract. + */ + private static boolean isDecodableGrid(String grid) { + if (grid == null) return false; + int len = grid.length(); + if (len != 2 && len != 4 && len != 6) return false; + if (grid.equalsIgnoreCase("RR73") || grid.equalsIgnoreCase("RR")) return false; + return hasValidGridChars(grid); + } + + /** + * Check that {@code grid} uses the Maidenhead alphabet at each position (case-insensitive): + * field pair {@code A-R}, square pair {@code 0-9}, and subsquare pair {@code A-X}. The length + * is assumed already validated to 2/4/6 by {@link #isDecodableGrid}. This mirrors the + * {@code [A-Ra-r]{2}[0-9]{2}[A-Xa-x]{2}} locator grammar documented in + * {@code GridOsmMapView} and the shape checks in {@link #checkMaidenhead}. + */ + private static boolean hasValidGridChars(String grid) { + for (int i = 0; i < grid.length(); i++) { + char c = Character.toUpperCase(grid.charAt(i)); + switch (i) { + case 0: + case 1: + if (c < 'A' || c > 'R') return false; + break; + case 2: + case 3: + if (c < '0' || c > '9') return false; + break; + default: // 4, 5 + if (c < 'A' || c > 'X') return false; + break; + } + } + return true; + } + /** * Calculate latitude/longitude from a 4-character or 6-character Maidenhead grid. Returns null if grid data is invalid. For 4-character grids, 'll' is appended to use the center position. * @@ -37,14 +89,11 @@ public class MaidenheadGrid { * @return LatLng latitude/longitude, or null if data is invalid */ public static LatLng gridToLatLng(String grid) { - if (grid==null) return null; - if (grid.length()==0) return null; - //Check if it conforms to Maidenhead grid rules - if (grid.length() != 2&&grid.length() != 4 && grid.length() != 6) { - return null; - } - if (grid.equalsIgnoreCase("RR73")) return null; - if (grid.equalsIgnoreCase("RR")) return null; + if (!isDecodableGrid(grid)) return null; + // Normalize once with a fixed locale: the default-locale toUpperCase() below + // breaks under Turkish-style locales ('i' -> 'İ', a multi-byte char that shifts + // every getBytes() index). After this, those calls are locale-safe no-ops. + grid = grid.toUpperCase(Locale.ROOT); double x=0; double y=0; double z=0; @@ -106,9 +155,10 @@ public static LatLng gridToLatLng(String grid) { public static LatLng[] gridToPolygon(String grid) { - if (grid.length() != 2 && grid.length() != 4 && grid.length() != 6) { - return null; - } + if (!isDecodableGrid(grid)) return null; + // See gridToLatLng: fixed-locale normalization keeps the byte arithmetic + // correct on devices whose default locale re-maps ASCII case ('i' -> 'İ'). + grid = grid.toUpperCase(Locale.ROOT); LatLng[] latLngs = new LatLng[4]; //Latitude 1 diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/maidenhead/MaidenheadGridTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/maidenhead/MaidenheadGridTest.java index ad72c7f5f..f268377fa 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/maidenhead/MaidenheadGridTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/maidenhead/MaidenheadGridTest.java @@ -8,6 +8,8 @@ import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; +import java.util.Locale; + /** * Exercise the Maidenhead grid math: grid->LatLng, LatLng->grid, distance, * and the format validator. Robolectric is required because the production @@ -110,6 +112,30 @@ public void gridToLatLng_badLength_returnsNull() { assertThat(MaidenheadGrid.gridToLatLng("ABCDEFG")).isNull(); } + @Test + public void gridToLatLng_rightLengthWrongAlphabet_returnsNull() { + // A token of a legal length (2/4/6) but that is not a Maidenhead locator + // must be rejected, not coerced into an arbitrary LatLng. Before this + // guard an ADIF GRIDSQUARE of "1234" (or any 4-char junk) decoded to a + // bogus point that was plotted on the map and fed into distance stats. + assertThat(MaidenheadGrid.gridToLatLng("1234")).isNull(); // digits in field slots + assertThat(MaidenheadGrid.gridToLatLng("FN4X")).isNull(); // letter in a digit slot + assertThat(MaidenheadGrid.gridToLatLng("ZZ99")).isNull(); // field letters past R + assertThat(MaidenheadGrid.gridToLatLng("AB1@")).isNull(); // symbol in a digit slot + assertThat(MaidenheadGrid.gridToLatLng("FN42zz")).isNull(); // subsquare past x + assertThat(MaidenheadGrid.gridToLatLng("1A")).isNull(); // 2-char field with a digit + } + + @Test + public void gridToLatLng_validGridsStillDecode() { + // Regression guard: the alphabet check must be a no-op for real locators + // of every supported length and case. + assertThat(MaidenheadGrid.gridToLatLng("FN")).isNotNull(); + assertThat(MaidenheadGrid.gridToLatLng("FN42")).isNotNull(); + assertThat(MaidenheadGrid.gridToLatLng("IO91wm")).isNotNull(); + assertThat(MaidenheadGrid.gridToLatLng("io91WM")).isNotNull(); // mixed case + } + // ---------- getGridSquare ---------- @Test @@ -332,6 +358,23 @@ public void gridToPolygon_badLength_returnsNull() { assertThat(MaidenheadGrid.gridToPolygon("ABCDE")).isNull(); } + @Test + public void gridToPolygon_nullReturnsNullNotNPE() { + // Unlike its sibling gridToLatLng, gridToPolygon had no null guard and + // NPE'd on grid.length(). A null gridsquare reaching the GridPolygon + // overlay must yield null, not throw. + assertThat(MaidenheadGrid.gridToPolygon(null)).isNull(); + } + + @Test + public void gridToPolygon_rightLengthWrongAlphabet_returnsNull() { + // Same alphabet contract as gridToLatLng: a legal-length non-locator + // token must be rejected rather than drawn as a bogus cell outline. + assertThat(MaidenheadGrid.gridToPolygon("1234")).isNull(); + assertThat(MaidenheadGrid.gridToPolygon("FN4X")).isNull(); + assertThat(MaidenheadGrid.gridToPolygon("ZZ99")).isNull(); + } + @Test public void gridToPolygon_cornersFormARectangle() { // latLngs[0]/[1] share lat1; [2]/[3] share lat2; [0]/[3] share lng1; @@ -418,4 +461,28 @@ public void checkMaidenhead_rejectsTwoCharField() { // Unlike gridToLatLng, the validator does not accept 2-char fields. assertThat(MaidenheadGrid.checkMaidenhead("FN")).isFalse(); } + + // ---------- locale safety ---------- + + @Test + public void gridToLatLng_lowerCase_turkishDefaultLocale_decodesCorrectly() { + // Default-locale toUpperCase() maps 'i' to dotted-capital 'İ' (U+0130) under + // Turkish locales — a multi-byte UTF-8 char that shifts every getBytes() + // index and breaks the byte arithmetic. The decoders must normalize with a + // fixed locale so "io91wm" decodes identically everywhere. + Locale saved = Locale.getDefault(); + Locale.setDefault(new Locale("tr", "TR")); + try { + LatLng p = MaidenheadGrid.gridToLatLng("io91wm"); + assertThat(p).isNotNull(); + assertThat(p.latitude).isWithin(POS_TOL).of(51.521); + assertThat(p.longitude).isWithin(POS_TOL).of(-0.125); + + LatLng[] poly = MaidenheadGrid.gridToPolygon("io91wm"); + assertThat(poly).isNotNull(); + assertThat(poly[0].latitude).isWithin(POS_TOL).of(51.5); + } finally { + Locale.setDefault(saved); + } + } } From dfd148899069da1c24c0f9c597f36ccaedc9af1b Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 08:12:45 -0500 Subject: [PATCH 047/113] Fix (tr)uSDX audio-over-CAT crash when the "US" marker straddles a serial read boundary (#645) * Fix (tr)uSDX audio-over-CAT crash when the "US" marker straddles a read boundary The (tr)uSDX rig multiplexes ';'-terminated ASCII CAT commands with raw 8-bit PCM audio in a single serial byte stream. In TrUSDXRig.onReceiveData the 2-byte "US" stream-start marker is recognised against the accumulated command buffer (any un-terminated leftover from a previous read plus the current segment), but the audio payload was sliced from the current segment alone with a hard-coded offset: byte[] wave = Arrays.copyOfRange(cutted, 2, cutted.length); When "US" straddles a serial-read boundary, part of the marker arrives in a previous read and the current segment can be shorter than 2 bytes. Then copyOfRange(from=2, to<2) throws IllegalArgumentException. On the Bluetooth SPP path (main Looper, no try/catch) that crashes the app; on the USB path it tears down and reconnects the CAT session mid-RX. Concretely: a read ending in a lone 'U' buffers "U", the next read "S;" yields a 1-byte (or empty) segment while the buffer reads "US", and the slice throws. Fix: compute a bounds-safe start via a new pure helper waveStartOffset(leftoverLen, segmentLen) = clamp(2 - leftoverLen, 0, segmentLen). This also corrects a latent off-by-N audio corruption in the straddle case (payload really begins at 2 - leftoverLen, not 2) and is byte-identical in the common case where the whole marker is in one segment. The sibling trailing-remainder path already guards remain.length >= 2. Extracted the geometry into an internal static helper (mirroring indexOfByte) and covered it with fail-before unit tests in TrUSDXRigTest, including an exhaustive check that the returned offset never yields an invalid copyOfRange. Co-Authored-By: Claude Opus 4.8 (1M context) * Fix waveStartOffset Javadoc range typo (from <= from <= to -> 0 <= from <= to) Copilot review: docs-only correction of the copyOfRange invariant description. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/rigs/TrUSDXRig.java | 49 ++++++++++++- .../com/k1af/ft8af/rigs/TrUSDXRigTest.java | 71 +++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/TrUSDXRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/TrUSDXRig.java index 8b30620a1..e933f6ecf 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/TrUSDXRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/TrUSDXRig.java @@ -143,6 +143,40 @@ static int indexOfByte(byte[] data, byte target) { return -1; } + /** + * Byte offset within the current ';'-terminated segment where the (tr)uSDX + * {@code "US"} stream-start marker's raw-audio payload begins. + * + *

The 2-byte {@code "US"} marker is recognised against the accumulated + * command buffer — any un-terminated leftover from a previous serial read + * plus this segment — but the audio payload must be sliced from this + * segment's raw bytes (decoding audio through a String corrupts it, see + * {@link #indexOfByte}). When the marker straddles a read boundary, + * {@code leftoverLen} of its bytes already arrived in the previous read, so + * only {@code 2 - leftoverLen} of the marker sit at the head of this + * segment. The result is clamped to {@code [0, segmentLen]} so the caller's + * {@link Arrays#copyOfRange} is always given {@code 0 <= from <= to}: a + * boundary split that leaves this segment shorter than the un-consumed + * marker yields an empty payload instead of throwing + * {@code IllegalArgumentException}. + * + * @param leftoverLen bytes of buffered, un-terminated command text that + * preceded this segment (0 when the marker is wholly in + * this segment) + * @param segmentLen length of this segment's byte slice + * @return a start offset in {@code [0, segmentLen]} + */ + static int waveStartOffset(int leftoverLen, int segmentLen) { + int start = 2 - leftoverLen; + if (start < 0) { + start = 0; + } + if (start > segmentLen) { + start = segmentLen; + } + return start; + } + @Override public void onReceiveData(byte[] data) { byte[] remain = data; @@ -166,6 +200,12 @@ public void onReceiveData(byte[] data) { onReceivedWaveData(cutted, true); rxStreaming = false; } else { + // Remember how many bytes of any un-terminated command text + // preceded this segment: the "US" marker below is matched + // against leftover + this segment, but its audio payload is + // sliced from this segment alone, so the split point depends on + // how much of the marker already arrived earlier. + int leftoverLen = buffer.length(); buffer.append(new String(cutted)); //begin parsing data Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(buffer.toString()); @@ -182,7 +222,14 @@ public void onReceiveData(byte[] data) { } } else if (cmd.equalsIgnoreCase("US")) { rxStreaming = true; - byte[] wave = Arrays.copyOfRange(cutted, 2, cutted.length); + // Slice the audio from just past the "US" marker. When the + // marker straddled a read boundary the marker head is in + // `leftoverLen`, so the payload can start before index 2 (or + // this segment may be shorter than the un-consumed marker); + // waveStartOffset keeps the range valid instead of letting + // copyOfRange throw. See waveStartOffset / PR notes. + int waveStart = waveStartOffset(leftoverLen, cutted.length); + byte[] wave = Arrays.copyOfRange(cutted, waveStart, cutted.length); onReceivedWaveData(wave); } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/TrUSDXRigTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/TrUSDXRigTest.java index 8e931d3fa..1e81b6860 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/TrUSDXRigTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/TrUSDXRigTest.java @@ -64,4 +64,75 @@ public void indexOfByte_findsDelimiterAtIndexZero() { byte[] data = new byte[] {(byte) ';', (byte) 0x80}; assertThat(TrUSDXRig.indexOfByte(data, (byte) ';')).isEqualTo(0); } + + // --------------------------------------------------------------------- + // waveStartOffset(): where the "US" stream-start marker's audio payload + // begins within the current ';'-terminated segment. The 2-byte "US" + // marker can straddle a serial-read boundary, so part of it may have + // arrived (and been buffered) in a previous read. + // --------------------------------------------------------------------- + + @Test + public void waveStartOffset_wholeMarkerInThisSegment() { + // leftover empty, segment = "US" + audio: payload starts after the + // 2-byte marker. + assertThat(TrUSDXRig.waveStartOffset(0, 10)).isEqualTo(2); + } + + @Test + public void waveStartOffset_markerSplitOneByteInLeftover() { + // Previous read left a lone 'U'; this segment starts with 'S' + audio, + // so only the 'S' (1 byte) of the marker is in this segment. + assertThat(TrUSDXRig.waveStartOffset(1, 5)).isEqualTo(1); + } + + @Test + public void waveStartOffset_wholeMarkerInLeftover() { + // Both marker bytes arrived previously; the whole segment is audio. + assertThat(TrUSDXRig.waveStartOffset(2, 5)).isEqualTo(0); + assertThat(TrUSDXRig.waveStartOffset(3, 5)).isEqualTo(0); + } + + @Test + public void waveStartOffset_isClampedToSegmentLength_neverProducesFromGreaterThanTo() { + // The crash case: the marker straddles the boundary and this segment is + // shorter than the un-consumed marker (e.g. leftover "U", segment "S", + // or an empty segment). The start must clamp to the segment length so + // Arrays.copyOfRange(segment, start, segment.length) gets from <= to. + assertThat(TrUSDXRig.waveStartOffset(1, 1)).isEqualTo(1); // "S" only + assertThat(TrUSDXRig.waveStartOffset(2, 0)).isEqualTo(0); // empty segment + assertThat(TrUSDXRig.waveStartOffset(0, 0)).isEqualTo(0); // empty segment, no leftover + assertThat(TrUSDXRig.waveStartOffset(0, 1)).isEqualTo(1); // only 'U' of "US" here + } + + @Test + public void waveStartOffset_resultAlwaysYieldsValidCopyOfRange() { + // Exhaustively confirm the helper never returns a start that would make + // Arrays.copyOfRange throw (from < 0, from > to, or from > length). + for (int leftover = 0; leftover <= 4; leftover++) { + for (int segLen = 0; segLen <= 4; segLen++) { + int start = TrUSDXRig.waveStartOffset(leftover, segLen); + assertThat(start).isAtLeast(0); + assertThat(start).isAtMost(segLen); + // Sanity: this is exactly the copyOfRange the caller performs. + byte[] segment = new byte[segLen]; + byte[] wave = java.util.Arrays.copyOfRange(segment, start, segLen); + assertThat(wave.length).isEqualTo(segLen - start); + } + } + } + + @Test + public void waveStartOffset_oldHardcodedOffsetWouldThrow_provingTheBug() { + // Before the fix the caller sliced Arrays.copyOfRange(segment, 2, len) + // unconditionally. When the "US" marker straddles a read boundary the + // segment can be shorter than 2, and copyOfRange(from=2, to<2) throws + // IllegalArgumentException ("2 > to") — the crash this fix removes. + try { + java.util.Arrays.copyOfRange(new byte[1], 2, 1); + throw new AssertionError("expected IllegalArgumentException from the old offset"); + } catch (IllegalArgumentException expected) { + // exactly the pre-fix crash + } + } } From 2731fda8304c631df621aebf83ad7b12c2761831 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 08:12:49 -0500 Subject: [PATCH 048/113] Fix FlexRadio TX-audio crash on a partial final packet (FT2 over network Flex) (#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix FlexRadio TX-audio crash: sendWaveData reads one past the buffer on a partial final packet FlexRadio.sendWaveData streams the interleaved-stereo TX waveform to the radio in fixed 256-value packets. The inner packetiser loop read temp[count] and only tested `count > temp.length` *after* the read, so when the total number of stereo values was not a whole multiple of 256 the final packet read one element past the array: for (int i = 0; i < voice.length; i++) { voice[i] = temp[count]; // reads before checking count++; if (count > temp.length) break; } The overrun throws an uncaught ArrayIndexOutOfBoundsException on the TX send Thread (whose only catch is for UnknownHostException), crashing the whole app. Reachability: temp.length == 2 * waveform samples. FT8 (303360 samples) and FT4 (120960) at the Flex 24 kHz rate are both multiples of 128, so temp.length is a multiple of 256 and the bug stayed dormant. FT2, however, generates 105 * round(0.024*24000) = 60480 samples -> 120960 stereo values, and 120960 % 256 == 128, so every FT2 transmit over a network Flex rig reads temp[120960] and crashes. Fix: extract the packetiser into a pure, unit-tested helper `fillVoicePacket(stereo, from, packet)` that copies at most packet.length samples, zero-pads the tail of the final partial packet, and never reads past the end of the buffer. Behaviour is byte-identical for the already-aligned FT8/FT4 buffers; the only change is that the previously crashing partial-packet case now sends one final silence-padded packet. Adds FlexRadioVoicePacketTest (pure JVM, matching FlexRadioAudioWriteTest): covers a full packet, the FT2 partial-final-packet case that used to overrun, reused-buffer zero-fill, an at-end no-op, and a clean exact-multiple walk. Co-Authored-By: Claude Opus 4.8 (1M context) * Fix stale/orphaned comments around fillVoicePacket (Copilot review) Move the sendWaveData Javadoc back above sendWaveData (it was orphaned when fillVoicePacket was inserted between them), and correct the two packet-size comments that still said '240 samples' — the packet is 256 interleaved stereo floats (128 L/R frames). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/flex/FlexRadio.java | 44 +++++-- .../ft8af/flex/FlexRadioVoicePacketTest.java | 107 ++++++++++++++++++ 2 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/flex/FlexRadioVoicePacketTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexRadio.java b/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexRadio.java index f957f1a43..4c8fdcefe 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexRadio.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/flex/FlexRadio.java @@ -485,6 +485,40 @@ public synchronized void disConnect() { } } + /** + * Fill one stereo TX packet from {@code stereo} starting at sample index + * {@code from}, and return the index one past the last sample copied. + * + *

Copies at most {@code packet.length} samples; when fewer remain in + * {@code stereo} the tail of {@code packet} is zero-filled (silence padding + * for the final, partial packet). It never reads past the end of + * {@code stereo}. + * + *

The historic inline loop read {@code stereo[from]} and only tested the + * bound after the read, so a {@code stereo} length that was not a + * whole multiple of {@code packet.length} ran one element past the array and + * threw {@link ArrayIndexOutOfBoundsException} on the TX send thread + * (uncaught → whole-app crash). That happens for any waveform whose sample + * count is not a multiple of {@code packet.length / 2}; e.g. FT2 at 24 kHz + * generates 60480 samples → 120960 interleaved stereo values, which is not a + * multiple of 256, so the last packet overran. + * + * @param stereo interleaved L/R sample buffer + * @param from index of the first sample to copy ({@code 0 <= from <= stereo.length}) + * @param packet destination packet buffer, fully (over)written + * @return {@code min(from + packet.length, stereo.length)} + */ + static int fillVoicePacket(float[] stereo, int from, float[] packet) { + int i = 0; + for (; i < packet.length && from < stereo.length; i++, from++) { + packet[i] = stereo[from]; + } + for (; i < packet.length; i++) { + packet[i] = 0f; + } + return from; + } + /** * FlexRadio transmits at 24000 sample rate; also converts mono to stereo * @param data audio @@ -517,15 +551,11 @@ public void run() { - float[] voice=new float[256];//Because it's stereo, 240*2 + float[] voice=new float[256];//256 interleaved stereo floats (128 L/R frames) per packet //for (int j = 0; j <3 ; j++) { - for (int i = 0; i < voice.length; i++) { - voice[i] = temp[count]; - count++; - if (count > temp.length) break; - } + count = fillVoicePacket(temp, count, voice); //byte[] send = vita.audioDataToVita(packetCount, streamTxId,0x534c2d, voice); //daxTxAudioStreamId=0x0084000001&0x00000000ffffffff; @@ -542,7 +572,7 @@ public void run() { } catch (UnknownHostException e) { throw new RuntimeException(e); } - if (count>temp.length) break; + if (count>=temp.length) break; //} while (isPttOn) { if (System.currentTimeMillis() - now >= 5) {//5ms per cycle, 256 floats per cycle diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/flex/FlexRadioVoicePacketTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/flex/FlexRadioVoicePacketTest.java new file mode 100644 index 000000000..5795d1d77 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/flex/FlexRadioVoicePacketTest.java @@ -0,0 +1,107 @@ +package com.k1af.ft8af.flex; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Pure-JVM coverage for {@link FlexRadio#fillVoicePacket(float[], int, float[])}, + * the packetiser step of the Flex DAX TX-audio stream. + * + *

{@code sendWaveData} walks the interleaved-stereo waveform in fixed 256-value + * packets. The historic inline loop read {@code temp[count]} and only checked the + * bound after the read, so when the total number of stereo values was not + * a whole multiple of 256 the final packet read one element past the array and + * threw an uncaught {@link ArrayIndexOutOfBoundsException} on the TX send thread — + * crashing the whole app. FT2 at 24 kHz produces 60480 samples (120960 interleaved + * values, {@code 120960 % 256 == 128}), so every FT2 transmit over a network Flex + * rig hit it. These tests pin the bounded, zero-padded behaviour. + */ +public class FlexRadioVoicePacketTest { + + /** Packet size used by {@code sendWaveData} (256 interleaved stereo floats — 128 L/R frames). */ + private static final int PACKET = 256; + + @Test + public void fillsFullPacketAndAdvancesByPacketSize() { + float[] stereo = new float[PACKET * 2]; + for (int i = 0; i < stereo.length; i++) { + stereo[i] = i; + } + float[] packet = new float[PACKET]; + + int next = FlexRadio.fillVoicePacket(stereo, 0, packet); + + assertThat(next).isEqualTo(PACKET); + assertThat(packet[0]).isEqualTo(0f); + assertThat(packet[PACKET - 1]).isEqualTo(PACKET - 1); + } + + @Test + public void partialFinalPacketIsZeroPadded_notOutOfBounds() { + // 120960 = FT2 @ 24 kHz stereo value count; not a multiple of 256. + float[] stereo = new float[120960]; + for (int i = 0; i < stereo.length; i++) { + stereo[i] = 1f; + } + float[] packet = new float[PACKET]; + + // The final packet starts here with only 128 values left (120960 - 120832). + int from = 120832; + int next = FlexRadio.fillVoicePacket(stereo, from, packet); // must not throw + + assertThat(next).isEqualTo(120960); + // First 128 slots are real samples, the remaining 128 are silence padding. + assertThat(packet[127]).isEqualTo(1f); + assertThat(packet[128]).isEqualTo(0f); + assertThat(packet[PACKET - 1]).isEqualTo(0f); + } + + @Test + public void reusedPacketBufferIsFullyOverwritten() { + // A caller reusing a dirty buffer must still see the tail zeroed. + float[] stereo = {5f, 6f, 7f}; + float[] packet = new float[PACKET]; + for (int i = 0; i < packet.length; i++) { + packet[i] = 99f; + } + + int next = FlexRadio.fillVoicePacket(stereo, 0, packet); + + assertThat(next).isEqualTo(3); + assertThat(packet[0]).isEqualTo(5f); + assertThat(packet[2]).isEqualTo(7f); + assertThat(packet[3]).isEqualTo(0f); + assertThat(packet[PACKET - 1]).isEqualTo(0f); + } + + @Test + public void fromAtEndProducesSilentPacketAndDoesNotAdvance() { + float[] stereo = new float[512]; + float[] packet = new float[PACKET]; + + int next = FlexRadio.fillVoicePacket(stereo, stereo.length, packet); + + assertThat(next).isEqualTo(stereo.length); + assertThat(packet[0]).isEqualTo(0f); + assertThat(packet[PACKET - 1]).isEqualTo(0f); + } + + @Test + public void walksExactMultipleToCompletionWithoutOverrun() { + // 512 stereo values = exactly two packets; the loop must terminate cleanly. + float[] stereo = new float[PACKET * 2]; + float[] packet = new float[PACKET]; + + int count = 0; + int packets = 0; + while (count < stereo.length) { + count = FlexRadio.fillVoicePacket(stereo, count, packet); + packets++; + if (count >= stereo.length) break; + } + + assertThat(packets).isEqualTo(2); + assertThat(count).isEqualTo(stereo.length); + } +} From dd529a11768b18d8fc4598f2c2ec8eb3b9bb93b1 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 08:12:54 -0500 Subject: [PATCH 049/113] Fix Bluetooth CAT-write crash when the link drops mid-QSO (disconnect race) (#647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix Bluetooth CAT-write crash when the link drops mid-QSO (disconnect race) A background INTENT_ACTION_DISCONNECT (rig powered off / RFCOMM link dropped) runs disconnect() on another thread, which nulls the socket field. A CAT/TX worker already past the `connected` check in BluetoothSerialService.write() / BluetoothSerialSocket.write() then dereferenced the now-null socket (socket.write / socket.getOutputStream) → NullPointerException. That NPE escaped BluetoothRigConnector.sendCommand's IOException-only catch and crashed the app on a background thread. This is the check-then-act (TOCTOU) race the USB-serial path was already hardened against in CableSerialPort.writeIfOpen; the Bluetooth twin never received the analogous guard. Both write() layers now: - mark `socket` volatile (written on connect, nulled on disconnect, read on the CAT/TX thread), and - snapshot the field ONCE into a local and route it through the new pure BluetoothSerialSocket.writeIfConnected(connected, sink, data) helper, which reports a torn-down link as the "not connected" IOException the caller already handles instead of NPEing. Added BluetoothSerialWriteTest (pure JVM, 4 cases) covering the guard, including the race case (connected==true but the snapshotted sink is null). Co-Authored-By: Claude Opus 4.8 (1M context) * Make Bluetooth 'connected' flags volatile to complete the disconnect-race fix Copilot review: 'connected' in BluetoothSerialSocket and BluetoothSerialService is read/written across connect, read-loop, disconnect, and CAT/TX threads but was not volatile, so the writeIfConnected guards could act on a stale value even though 'socket' is volatile. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../bluetooth/BluetoothSerialService.java | 21 ++++-- .../bluetooth/BluetoothSerialSocket.java | 52 ++++++++++++-- .../bluetooth/BluetoothSerialWriteTest.java | 69 +++++++++++++++++++ 3 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/bluetooth/BluetoothSerialWriteTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/BluetoothSerialService.java b/ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/BluetoothSerialService.java index f35bd558a..c14152d40 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/BluetoothSerialService.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/BluetoothSerialService.java @@ -38,9 +38,15 @@ private static class QueueItem { private final IBinder binder; private final Queue queue1, queue2; - private BluetoothSerialSocket socket; + // volatile: assigned on the connect thread, cleared to null on the disconnect + // thread, read on the CAT/TX thread (write()). Readers snapshot it into a + // local before check-and-use — see write(). + private volatile BluetoothSerialSocket socket; private BluetoothSerialListener listener; - private boolean connected; + // volatile for the same reason as socket: written by connect()/disconnect() and + // the background onSerial* callbacks, read by binder callers (write()) — without + // it the writeIfConnected guard can see a stale value under the Java memory model. + private volatile boolean connected; /** * Lifecylce @@ -84,9 +90,14 @@ public void disconnect() { } public void write(byte[] data) throws IOException { - if(!connected) - throw new IOException("not connected"); - socket.write(data); + // Snapshot the volatile socket ONCE — disconnect() may null it on another + // thread between the connected check and the write (check-then-act race). + // writeIfConnected converts a torn-down link into the "not connected" + // IOException BluetoothRigConnector.sendCommand already handles, never an + // uncaught NPE that would crash the CAT/TX worker. See + // BluetoothSerialSocket.writeIfConnected. + final BluetoothSerialSocket s = socket; + BluetoothSerialSocket.writeIfConnected(connected, s == null ? null : s::write, data); } public void attach(BluetoothSerialListener listener) { diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/BluetoothSerialSocket.java b/ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/BluetoothSerialSocket.java index 30b5b6d13..c3a5a8ef2 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/BluetoothSerialSocket.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/BluetoothSerialSocket.java @@ -32,8 +32,16 @@ public class BluetoothSerialSocket implements Runnable { private final Context context; private BluetoothSerialListener listener; private final BluetoothDevice device; - private BluetoothSocket socket; - private boolean connected; + // volatile: assigned on the RFCOMM connect thread (run()), cleared to null on + // the disconnect thread (disconnect(), driven by the background + // INTENT_ACTION_DISCONNECT receiver or the read loop's error path), and read + // on the CAT/TX thread (write()). Readers MUST snapshot it into a local before + // check-and-use — see write()/writeIfConnected. + private volatile BluetoothSocket socket; + // volatile for the same reason as socket: set on the connect thread, cleared on + // the disconnect thread, read on the CAT/TX thread (write()) — without it those + // reads can see a stale value under the Java memory model. + private volatile boolean connected; public BluetoothSerialSocket(Context context, BluetoothDevice device) { if(context instanceof Activity) @@ -89,9 +97,45 @@ void disconnect() { } void write(byte[] data) throws IOException { - if (!connected) + // Snapshot the volatile socket ONCE. disconnect() (the background + // INTENT_ACTION_DISCONNECT receiver, or the read loop's error path) may + // null `socket` on another thread between the connected check and the + // getOutputStream() call. Reading the field twice let that concurrent + // disconnect turn a passed null-check into a null dereference — an NPE + // that escapes BluetoothRigConnector.sendCommand's IOException-only catch + // and crashes the CAT/TX worker mid-QSO. Passing the single snapshot to + // writeIfConnected reports a torn-down link as the "not connected" + // IOException the caller already handles instead. Mirrors the USB-serial + // fix in CableSerialPort.writeIfOpen. + final BluetoothSocket s = socket; + writeIfConnected(connected, s == null ? null : d -> s.getOutputStream().write(d), data); + } + + /** + * A sink that writes one frame to the live link — the socket's output stream + * in production, a capture in tests. + */ + @FunctionalInterface + interface ByteSink { + void write(byte[] data) throws IOException; + } + + /** + * Issue a write only if the link is still up. {@code sink} is the caller's + * single snapshot of the (volatile) socket's writer, so a concurrent + * {@link #disconnect()} that nulls the field afterwards cannot affect this + * call. When the link is down ({@code !connected}) or the snapshot was + * already null (disconnect() ran first, before {@code connected} was cleared), + * throw the {@code "not connected"} {@link IOException} the CAT connector + * already handles ({@code BluetoothRigConnector.sendCommand}) rather than + * NPEing on a nulled socket and crashing the CAT/TX worker thread. Mirrors + * {@code CableSerialPort.writeIfOpen} on the USB-serial path. + */ + static void writeIfConnected(boolean connected, ByteSink sink, byte[] data) throws IOException { + if (!connected || sink == null) { throw new IOException("not connected"); - socket.getOutputStream().write(data); + } + sink.write(data); } /** diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/bluetooth/BluetoothSerialWriteTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/bluetooth/BluetoothSerialWriteTest.java new file mode 100644 index 000000000..342d63ce9 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/bluetooth/BluetoothSerialWriteTest.java @@ -0,0 +1,69 @@ +package com.k1af.ft8af.bluetooth; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Pure-JVM coverage for {@link BluetoothSerialSocket#writeIfConnected}, the + * disconnect-race guard behind CAT-over-Bluetooth writes. + * + *

The bug this covers: a background {@code INTENT_ACTION_DISCONNECT} (rig + * powered off / RFCOMM link dropped mid-QSO) runs {@code disconnect()} on another + * thread, which nulls the socket. A CAT/TX worker already past the + * {@code connected} check then dereferenced the now-null socket + * ({@code socket.getOutputStream()} / {@code socket.write()}) → a + * {@link NullPointerException}. That NPE escaped + * {@code BluetoothRigConnector.sendCommand}'s {@code IOException}-only catch and + * crashed the app on a background thread. Both {@code write()} layers now + * snapshot the socket once and route it through {@code writeIfConnected}, which + * reports the torn-down link as the {@code "not connected"} {@link IOException} + * the connector already handles instead of NPEing — mirroring the USB-serial + * {@code CableSerialPort.writeIfOpen} fix. + */ +public class BluetoothSerialWriteTest { + + @Test + public void nullSink_afterConcurrentDisconnect_throwsNotConnectedNotNpe() throws IOException { + // The crash case: the caller passed the connected==true check, but a + // concurrent disconnect() nulled the socket, so the snapshot -> sink is + // null. Must be a clean IOException, never an NPE. + IOException e = assertThrows(IOException.class, + () -> BluetoothSerialSocket.writeIfConnected(true, null, new byte[]{1, 2, 3})); + assertThat(e).isNotInstanceOf(NullPointerException.class); + assertThat(e).hasMessageThat().contains("not connected"); + } + + @Test + public void notConnected_throwsNotConnectedAndDoesNotWrite() { + // Link already down: report "not connected" without touching the sink. + IOException e = assertThrows(IOException.class, + () -> BluetoothSerialSocket.writeIfConnected(false, + d -> { throw new AssertionError("must not write while disconnected"); }, + new byte[]{0})); + assertThat(e).hasMessageThat().contains("not connected"); + } + + @Test + public void connectedOpenSink_writesExactBytes() throws IOException { + AtomicReference got = new AtomicReference<>(); + byte[] src = "FA021074000;".getBytes(); + + BluetoothSerialSocket.writeIfConnected(true, got::set, src); + + assertThat(got.get()).isSameInstanceAs(src); + } + + @Test + public void connectedSink_propagatesIoException() { + // A real write failure (socket died mid-write) must surface as IOException + // for sendCommand's catch to report — not be swallowed or masked. + assertThrows(IOException.class, + () -> BluetoothSerialSocket.writeIfConnected(true, + d -> { throw new IOException("socket died"); }, new byte[]{0})); + } +} From ac169cc7932849986cba2c0f13521c166be0a592 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 08:13:02 -0500 Subject: [PATCH 050/113] Fix ADIF import truncating field values that contain '>' (#648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix ADIF import truncating field values that contain '>' The web-logger upload parser (LogFileImport.getLogRecords) and the shared-log import parser (ImportSharedLogs.parseLogRecords) split each ADIF field on EVERY '>' and kept the token before the second one. An ADIF field is VALUE where LEN is the authoritative byte length, so a value may legally contain '>' (free-text COMMENT/NOTES/QSLMSG and similar). Such a value was silently truncated at its first interior '>' on import -- e.g. a comment "hello>world" became "hello", corrupting logged QSO data. Root cause: the value slice used field.split(">")[1] instead of honouring the already-parsed declared length. Fix both parsers to split only at the FIRST '>' (indexOf) into header + raw value, then slice the raw value to the declared length -- matching extractFieldValue's documented contract ("everything after the first '>'"). Valid values without '>' decode identically. Extracted LogFileImport.parseRecord as a static, Android-free helper so the field parsing is unit-testable per the project's testing convention. Added regression tests to both ImportSharedLogsTest and LogFileImportTest (pure-JVM helpers plus an end-to-end file import); updated the stray-'>' edge case to the corrected length-based slice. Co-Authored-By: Claude Opus 4.8 (1M context) * Slice ADIF field values by UTF-8 byte length and uppercase keys with Locale.US Copilot review: ADIF declares a UTF-8 byte count (what this app's own export writes via AdifFormat.utf8Length), but both importers sliced by UTF-16 chars — over-reading past non-ASCII values into the following inter-field whitespace. Add AdifFormat.sliceByUtf8Length (never splits a code point, clamps to available text) and use it in LogFileImport.parseRecord and ImportSharedLogs.extractFieldValue. Field-name keys now uppercase with Locale.US so Turkish-style locales can't turn GRIDSQUARE into GRİDSQUARE. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/log/AdifFormat.java | 28 +++++ .../com/k1af/ft8af/log/ImportSharedLogs.java | 45 +++++--- .../com/k1af/ft8af/log/LogFileImport.java | 80 +++++++++----- .../com/k1af/ft8af/log/AdifFormatTest.java | 39 +++++++ .../k1af/ft8af/log/ImportSharedLogsTest.java | 63 +++++++++-- .../com/k1af/ft8af/log/LogFileImportTest.java | 101 ++++++++++++++++++ 6 files changed, 302 insertions(+), 54 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifFormat.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifFormat.java index 41fc757e2..45553f9e9 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifFormat.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifFormat.java @@ -103,4 +103,32 @@ public static int utf8Length(String value) { } return len; } + + /** + * The longest prefix of {@code raw} whose UTF-8 encoding fits in {@code byteLen} + * bytes — the reader-side counterpart of {@link #utf8Length}. ADIF's + * {@code } declares a UTF-8 byte count, so an importer that + * slices {@code len} UTF-16 chars over-reads past any non-ASCII value into the + * whitespace/text that follows it (e.g. LEN=9 for "Café QSO" keeps a trailing + * space). Never splits a code point: a LEN that ends mid-character keeps only the + * complete characters that fit. A LEN larger than the whole string returns the + * whole string (a truncated record keeps what is there). + */ + public static String sliceByUtf8Length(String raw, int byteLen) { + if (raw == null || byteLen <= 0) { + return ""; + } + int bytes = 0; + int i = 0; + while (i < raw.length()) { + int cp = raw.codePointAt(i); + int cpBytes = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4; + if (bytes + cpBytes > byteLen) { + break; + } + bytes += cpBytes; + i += Character.charCount(cp); + } + return raw.substring(0, i); + } } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/ImportSharedLogs.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/ImportSharedLogs.java index f400dd812..0148c3ebb 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/log/ImportSharedLogs.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/ImportSharedLogs.java @@ -10,6 +10,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; +import java.util.Locale; public class ImportSharedLogs { private static final String TAG = "ImportSharedLogs"; @@ -113,11 +114,17 @@ static ArrayList> parseLogRecords(String logBody) { for (String field : fields) {//Parse each raw record if (field.length() > 1) {//If it can be parsed - String[] values = field.split(">");//Split field name and value - - if (values.length > 1) {//If it can be parsed - if (values[0].contains(":")) {//Split field name and field length; field name before colon, length after - String[] ttt = values[0].split(":"); + // Split at the FIRST '>' only: then the value. + // The value's length is governed by the declared LEN, so it may + // itself contain '>' (e.g. free-text COMMENT/NOTES); splitting on + // every '>' truncated such values at the first interior one. + int gt = field.indexOf('>');//End of the field header + + if (gt > 0) {//If it can be parsed + String header = field.substring(0, gt);//NAME:LEN[:TYPE] + String rawValue = field.substring(gt + 1);//Everything after the header + if (header.contains(":")) {//Split field name and field length; field name before colon, length after + String[] ttt = header.split(":"); if (ttt.length > 1) { String name = ttt[0];//Field name int valueLen = Integer.parseInt(ttt[1]);//Field length @@ -125,8 +132,8 @@ static ArrayList> parseLogRecords(String logBody) { continue;//Skip pathological field lengths } if (valueLen > 0) { - String value = extractFieldValue(values[1], valueLen);//Field value - record.put(name.toUpperCase(), value);//Save field, key must be uppercase + String value = extractFieldValue(rawValue, valueLen);//Field value + record.put(name.toUpperCase(Locale.US), value);//Save field, key must be uppercase } } @@ -144,22 +151,26 @@ static ArrayList> parseLogRecords(String logBody) { } /** - * Slice an ADIF field value to its declared length, clamping the length down to the - * characters actually present. ADIF stores a field as {@code VALUE}, and the - * declared LEN can exceed the value that follows when a record is truncated or the writer - * padded the length. In that case the whole (shorter) value must be kept — the previous + * Slice an ADIF field value to its declared length, clamping the length down to what is + * actually present. ADIF stores a field as {@code VALUE} where LEN counts UTF-8 + * bytes (see {@link AdifFormat#utf8Length}), and the declared LEN can exceed the + * value that follows when a record is truncated or the writer padded the length. In that + * case the whole (shorter) value must be kept — the previous * {@code values[1].length() - 1} clamp silently dropped the last character (turning a value - * such as "FN31" into "FN3"), and reduced a single-character value to "". This mirrors the - * clamp used by {@link LogFileImport#getLogRecords()}. + * such as "FN31" into "FN3"), and reduced a single-character value to "". Slicing by + * {@link String#length()} chars instead of bytes over-read past non-ASCII values into the + * whitespace that follows them. This mirrors the slicing used by + * {@link LogFileImport#parseRecord}. * * @param raw the raw field value (everything after the first {@code >} up to the * next {@code <}) - * @param declaredLen the length declared in the field header (already known to be > 0) - * @return the value clamped to the declared length or to the raw length, whichever is smaller + * @param declaredLen the UTF-8 byte length declared in the field header (already known to + * be > 0) + * @return the value clamped to the declared byte length or to the raw length, whichever is + * smaller */ static String extractFieldValue(String raw, int declaredLen) { - int len = Math.min(declaredLen, raw.length()); - return len > 0 ? raw.substring(0, len) : ""; + return AdifFormat.sliceByUtf8Length(raw, declaredLen); } public void doImport(InputStream logFileStream, OnShareLogEvents onShareLogEvents) { diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/LogFileImport.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/LogFileImport.java index 3a6dfd668..dce1649bc 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/log/LogFileImport.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/LogFileImport.java @@ -11,6 +11,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; +import java.util.Locale; /** * Log file import. @@ -114,42 +115,63 @@ public ArrayList> getLogRecords() { continue; }//No tags found, skip parsing try { - HashMap record = new HashMap<>();//Create a record - String[] fields = s.split("<");//Split each field of the record - - for (String field : fields) {//Parse each raw record - - if (field.length() > 1) {//If it can be parsed - String[] values = field.split(">");//Split field name and value - - if (values.length > 1) {//If it can be parsed - if (values[0].contains(":")) {//Split field name and field length; field name before colon, length after - String[] ttt = values[0].split(":"); - if (ttt.length > 1) { - String name = ttt[0];//Field name - int valueLen = Integer.parseInt(ttt[1]);//Field length - if (valueLen > 0) { - if (values[1].length() < valueLen) { - valueLen = values[1].length(); - } - if (valueLen > 0) { - String value = values[1].substring(0, valueLen);//Field value - record.put(name.toUpperCase(), value);//Save field, key must be uppercase - } - } - } + records.add(parseRecord(s));//Save record + }catch (Exception e){ + errorLines.put(count,s.replace("<","<"));//Save the erroneous content. + importTask.readErrorCount=errorLines.size(); + } + } + return records; + } + /** + * Parse one raw ADIF record (the text between two {@code } markers) into a + * field map keyed by upper-case field name. + * + *

Extracted as a static, Android-free helper so the field parsing can be + * unit-tested without the file-reading constructor. Each field is + * {@code VALUE}; the value is sliced to the declared LEN. + * Splitting only at the first {@code '>'} (rather than on every + * {@code '>'} and keeping the second token) means a value that legally + * contains {@code '>'} — free-text COMMENT/NOTES and similar — is no longer + * truncated at its first interior {@code '>'}. + * + * @param rawRecord raw record text, e.g. {@code K1ABCFT8} + * @return the parsed fields (may be empty when nothing parses) + */ + static HashMap parseRecord(String rawRecord) { + HashMap record = new HashMap<>();//Create a record + String[] fields = rawRecord.split("<");//Split each field of the record + + for (String field : fields) {//Parse each raw record + + if (field.length() > 1) {//If it can be parsed + int gt = field.indexOf('>');//End of the field header + + if (gt > 0) {//If it can be parsed + String header = field.substring(0, gt);//NAME:LEN[:TYPE] + String rawValue = field.substring(gt + 1);//Everything after the header + if (header.contains(":")) {//Split field name and field length; field name before colon, length after + String[] ttt = header.split(":"); + if (ttt.length > 1) { + String name = ttt[0];//Field name + int valueLen = Integer.parseInt(ttt[1]);//Field length (UTF-8 bytes) + if (valueLen > 0) { + // LEN counts UTF-8 bytes (see AdifFormat.utf8Length), not + // UTF-16 chars: slicing by chars over-reads past a + // non-ASCII value into the whitespace that follows it. + String value = AdifFormat.sliceByUtf8Length(rawValue, valueLen);//Field value + if (!value.isEmpty()) { + record.put(name.toUpperCase(Locale.US), value);//Save field, key must be uppercase + } } } + } } - records.add(record);//Save record - }catch (Exception e){ - errorLines.put(count,s.replace("<","<"));//Save the erroneous content. - importTask.readErrorCount=errorLines.size(); } } - return records; + return record; } public int getErrorCount(){ return errorLines.size(); diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifFormatTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifFormatTest.java index 0553f0327..544af9706 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifFormatTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifFormatTest.java @@ -134,4 +134,43 @@ public void utf8Length_countsBytesNotChars() { public void utf8Length_nullIsZero() { assertThat(AdifFormat.utf8Length(null)).isEqualTo(0); } + + // ---- sliceByUtf8Length: the reader-side counterpart ---- + + @Test + public void sliceByUtf8Length_asciiBehavesLikeSubstring() { + assertThat(AdifFormat.sliceByUtf8Length("K1ABC extra", 5)).isEqualTo("K1ABC"); + } + + @Test + public void sliceByUtf8Length_countsBytesNotChars() { + // "Café QSO" is 8 chars but 9 UTF-8 bytes; a correct writer declares LEN=9. + // Slicing 9 CHARS from "Café QSO ' yields an empty value token under a positive declared - // length. The old code computed valueLen = -1 and threw - // StringIndexOutOfBoundsException from substring(0, -1); because the - // exception was caught at record scope, the WHOLE record (including the - // valid gridsquare) was lost. It must now keep the record with the good - // field intact. extractFieldValue clamps the empty value to "" rather - // than omitting the field, so CALL is present but empty. + // A stray '>' immediately after the header. The parser now splits only at + // the first '>', so the declared length (3) slices ">X" out of the two + // characters available before the next field — the '>' is data, not a + // delimiter. The point of this case remains: a malformed field must not + // crash or discard the surrounding valid gridsquare record. ArrayList> records = ImportSharedLogs.parseLogRecords(">XFN42"); assertThat(records).hasSize(1); assertThat(records.get(0).get("GRIDSQUARE")).isEqualTo("FN42"); - assertThat(records.get(0).get("CALL")).isEqualTo(""); + assertThat(records.get(0).get("CALL")).isEqualTo(">X"); } @Test @@ -84,6 +83,27 @@ public void multipleRecords_allParsed() { assertThat(records.get(1).get("CALL")).isEqualTo("W1AW"); } + @Test + public void fieldValueContainingGreaterThan_isPreserved() { + // ADIF stores a field as VALUE and LEN is the authoritative byte + // length, so a value may legally contain '>' (e.g. free-text COMMENT/NOTES). + // The old parser split each field on EVERY '>' and kept the token before the + // second one, silently truncating "hello>world" to "hello". Splitting only at + // the first '>' and honouring the declared length keeps the whole value. + HashMap first = ImportSharedLogs.parseLogRecords( + "hello>world").get(0); + assertThat(first.get("COMMENT")).isEqualTo("hello>world"); + } + + @Test + public void fieldValueWithMultipleGreaterThan_isClampedToDeclaredLength() { + HashMap first = ImportSharedLogs.parseLogRecords( + "a>b>cK1ABC").get(0); + assertThat(first.get("COMMENT")).isEqualTo("a>b>c"); + // A '>' in an earlier field's value must not corrupt later fields. + assertThat(first.get("CALL")).isEqualTo("K1ABC"); + } + @Test public void emptyOrTaglessBody_returnsNoRecords() { assertThat(ImportSharedLogs.parseLogRecords("")).isEmpty(); @@ -136,4 +156,31 @@ public void singleCharacterValueShorterThanDeclared_isNotEmptied() { // Old clamp turned this into "" (length 1 - 1 = 0); the value must survive. assertThat(ImportSharedLogs.extractFieldValue("X", 5)).isEqualTo("X"); } + + @Test + public void nonAsciiValue_slicedByUtf8Bytes_notChars() { + // LEN counts UTF-8 bytes: 9 bytes cover the 8-char "Caf\u00e9 QSO"; slicing 9 + // chars kept the field-separator space as part of the value. + assertThat(ImportSharedLogs.extractFieldValue("Caf\u00e9 QSO ", 9)).isEqualTo("Caf\u00e9 QSO"); + + HashMap first = ImportSharedLogs.parseLogRecords( + "Caf\u00e9 QSO K1ABC").get(0); + assertThat(first.get("COMMENT")).isEqualTo("Caf\u00e9 QSO"); + assertThat(first.get("CALL")).isEqualTo("K1ABC"); + } + + @Test + public void turkishDefaultLocale_keysStillAsciiUppercase() { + // Default-locale toUpperCase maps 'i' -> '\u0130' under Turkish locales, turning + // the "gridsquare" key into "GR\u0130DSQUARE" and losing the field. + Locale saved = Locale.getDefault(); + Locale.setDefault(new Locale("tr", "TR")); + try { + HashMap first = ImportSharedLogs.parseLogRecords( + "FN42").get(0); + assertThat(first.get("GRIDSQUARE")).isEqualTo("FN42"); + } finally { + Locale.setDefault(saved); + } + } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/log/LogFileImportTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/log/LogFileImportTest.java index eb4512293..03b060b4a 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/log/LogFileImportTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/log/LogFileImportTest.java @@ -19,6 +19,7 @@ import java.nio.file.Files; import java.util.ArrayList; import java.util.HashMap; +import java.util.Locale; /** * Drive {@link LogFileImport} against on-disk ADIF fixtures. The production @@ -103,6 +104,22 @@ public void malformedAdif_skipsBadRecordsAndReportsErrorCount() throws IOExcepti assertThat(imp.getErrorCount()).isEqualTo(1); } + @Test + public void fieldValueContainingGreaterThan_isPreservedEndToEnd() throws IOException { + // A field value may legally contain '>' (ADIF slices by the declared byte + // length, not by the next '>'). The old parser truncated it at the first + // interior '>'. Drive the full constructor -> getLogRecords path. + File f = tmp.newFile("gt.adi"); + try (FileOutputStream out = new FileOutputStream(f)) { + out.write("headerK1ABChello>world" + .getBytes(StandardCharsets.UTF_8)); + } + LogFileImport imp = new LogFileImport(task, f.getAbsolutePath()); + HashMap first = imp.getLogRecords().get(0); + assertThat(first.get("CALL")).isEqualTo("K1ABC"); + assertThat(first.get("COMMENT")).isEqualTo("hello>world"); + } + @Test public void emptyFile_returnsEmptyRecordList() throws IOException { File empty = tmp.newFile("empty.adi"); @@ -268,6 +285,90 @@ public void largeAdif_isNotTruncated() throws IOException { assertThat(imp.getFileContext()).doesNotContain("\u0000"); } + // ---- parseRecord(String): pure per-record field parsing ---- + + @Test + public void parseRecord_extractsFieldsByDeclaredLength() { + HashMap r = LogFileImport.parseRecord("K1ABCFT8"); + assertThat(r.get("CALL")).isEqualTo("K1ABC"); + assertThat(r.get("MODE")).isEqualTo("FT8"); + } + + @Test + public void parseRecord_uppercasesKeys() { + HashMap r = LogFileImport.parseRecord("K1ABC"); + assertThat(r).containsKey("CALL"); + assertThat(r).doesNotContainKey("call"); + } + + @Test + public void parseRecord_valueContainingGreaterThan_isPreserved() { + // Regression: the old field.split(">") kept only the token before the + // second '>', truncating "hello>world" to "hello". + HashMap r = LogFileImport.parseRecord("hello>world"); + assertThat(r.get("COMMENT")).isEqualTo("hello>world"); + } + + @Test + public void parseRecord_greaterThanInEarlierValue_doesNotCorruptLaterFields() { + HashMap r = + LogFileImport.parseRecord("a>b>cK1ABC"); + assertThat(r.get("COMMENT")).isEqualTo("a>b>c"); + assertThat(r.get("CALL")).isEqualTo("K1ABC"); + } + + @Test + public void parseRecord_nonAsciiValue_slicedByUtf8Bytes_notChars() { + // LEN is a UTF-8 byte count (this app's own export writes + // "Caf\u00e9 QSO "): 9 bytes cover the 8-char "Caf\u00e9 QSO". + // Slicing 9 CHARS kept the field separator space as part of the value. + HashMap r = LogFileImport.parseRecord("Caf\u00e9 QSO K1ABC"); + assertThat(r.get("COMMENT")).isEqualTo("Caf\u00e9 QSO"); + assertThat(r.get("CALL")).isEqualTo("K1ABC"); + } + + @Test + public void parseRecord_nonAsciiValue_truncatedRecordKeepsWhatIsThere() { + // Declared byte length exceeds the bytes present (truncated record). + HashMap r = LogFileImport.parseRecord("Caf\u00e9"); + assertThat(r.get("COMMENT")).isEqualTo("Caf\u00e9"); + } + + @Test + public void parseRecord_turkishDefaultLocale_keysStillAsciiUppercase() { + // Default-locale toUpperCase maps 'i' -> '\u0130' under Turkish locales, turning + // the "gridsquare" key into "GR\u0130DSQUARE" and losing the field. + Locale saved = Locale.getDefault(); + Locale.setDefault(new Locale("tr", "TR")); + try { + HashMap r = LogFileImport.parseRecord("FN42"); + assertThat(r.get("GRIDSQUARE")).isEqualTo("FN42"); + } finally { + Locale.setDefault(saved); + } + } + + @Test + public void parseRecord_declaredLengthLongerThanValue_keepsWholeValue() { + // declared for the 4-char value "W1AW". + HashMap r = LogFileImport.parseRecord("W1AW"); + assertThat(r.get("STATION_CALLSIGN")).isEqualTo("W1AW"); + } + + @Test + public void parseRecord_typeQualifiedHeader_usesLengthNotType() { + // ADIF allows ; the length is ttt[1], the type is ignored. + HashMap r = LogFileImport.parseRecord("14.07415"); + assertThat(r.get("FREQ")).isEqualTo("14.07415"); + } + + @Test(expected = NumberFormatException.class) + public void parseRecord_nonNumericLength_throwsForCallerToCount() { + // getLogRecords wraps this in a try/catch that records the bad line; the + // helper itself surfaces the parse failure rather than swallowing it. + LogFileImport.parseRecord("BADREC"); + } + private static String repeat(String s, int times) { StringBuilder sb = new StringBuilder(s.length() * times); for (int i = 0; i < times; i++) { From d5a38af0989919e79a39ab29914cdc20ccc8719c Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 15:03:03 -0500 Subject: [PATCH 051/113] Fix ADIF import losing FT4/FT2 mode (stored as MFSK, breaking round-trip) (#663) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FT4 and FT2 are ADIF SUBMODEs of the generic MFSK mode, not standalone modes, so FT8AF (and WSJT-X/JTDX/pota.app) export those QSOs as MODE=MFSK + SUBMODE=FT4. The ADIF import constructor (QSLRecord(HashMap)) read only the MODE field and ignored SUBMODE, so every imported FT4/FT2 QSO was stored as mode "MFSK" — losing the FT4/FT2 distinction. That corrupts mode-keyed dedup and band/mode filtering and breaks FT8AF's own export->import round-trip (a re-imported FT4 log comes back as MFSK). Both import paths (share/VIEW-intent ImportSharedLogs and the web-logger upload in LogHttpServer) funnel through this one constructor, and the ADIF field parser already captures SUBMODE into the record map, so the fix is localized: resolve MODE/SUBMODE back to the specific mode. Add AdifFormat.resolveImportMode(mode, submode), the reader-side inverse of the existing mfskSubmode(): when MODE is the generic MFSK and a non-empty SUBMODE is present, use the SUBMODE (trimmed, upper-cased); otherwise return MODE verbatim so all other modes are unaffected. Tests: AdifFormatTest covers the helper (FT4/FT2 resolution, case/trim, plain MFSK unchanged, standalone modes verbatim, null mode, inverse of mfskSubmode); QSLRecordTest covers the import constructor (MFSK+FT4->FT4, MFSK+FT2->FT2, plain MFSK stays MFSK, FT8 regression). Both FT4/FT2 constructor tests fail before the fix. Full unit suite green. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/log/AdifFormat.java | 29 ++++++++++ .../java/com/k1af/ft8af/log/QSLRecord.java | 5 +- .../com/k1af/ft8af/log/AdifFormatTest.java | 49 +++++++++++++++++ .../com/k1af/ft8af/log/QSLRecordTest.java | 54 +++++++++++++++++++ 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifFormat.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifFormat.java index 45553f9e9..b7bdf5ada 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifFormat.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifFormat.java @@ -58,6 +58,35 @@ public static String mfskSubmode(String rawMode) { return null; } + /** + * The effective stored mode for an imported ADIF record, resolving ADIF's + * MODE/SUBMODE split back to the single mode string FT8AF stores. This is the + * reader-side inverse of {@link #mfskSubmode}. + * + *

ADIF models FT4 and FT2 as SUBMODEs of the generic {@code MFSK} MODE, not as + * standalone modes. FT8AF — and WSJT-X, JTDX and pota.app — therefore export those + * QSOs as {@code MODE=MFSK} with {@code SUBMODE=FT4} (or {@code FT2}). Reading only + * MODE on import stored {@code "MFSK"}, silently losing the FT4/FT2 distinction: + * that corrupts mode-keyed dedup and band/mode filtering and breaks FT8AF's own + * export→import round-trip. When MODE is the generic {@code MFSK} and a non-empty + * SUBMODE is present, the SUBMODE is the more specific mode and is used (trimmed and + * upper-cased, mirroring {@link #mfskSubmode}); otherwise MODE is returned verbatim, + * so every other value (FT8, SSB, CW, a bare {@code FT4}, …) is unaffected. + * + * @param mode the ADIF MODE field value (may be null) + * @param submode the ADIF SUBMODE field value, or {@code null} when absent + * @return the mode string to store + */ + public static String resolveImportMode(String mode, String submode) { + if (mode != null && "MFSK".equalsIgnoreCase(mode.trim()) && submode != null) { + String s = submode.trim(); + if (!s.isEmpty()) { + return s.toUpperCase(Locale.US); + } + } + return mode; + } + /** "No report" sentinels stored in the SNR int fields; left unformatted so the logbook's * empty-report check still recognises them. */ private static final int NO_REPORT = -100; diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/QSLRecord.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/QSLRecord.java index 83d5170f9..1e41e945b 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/log/QSLRecord.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/QSLRecord.java @@ -155,7 +155,10 @@ public QSLRecord(HashMap map) { } } if (map.containsKey("MODE")) {//Mode - mode = map.get("MODE"); + // FT4/FT2 are ADIF SUBMODEs of the generic MFSK MODE (see AdifFormat.mfskSubmode), + // so they arrive as MODE=MFSK + SUBMODE=FT4. Resolve that back to the specific + // mode; reading MODE alone would store "MFSK" and lose the FT4/FT2 distinction. + mode = AdifFormat.resolveImportMode(map.get("MODE"), map.get("SUBMODE")); } else { mode = ""; } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifFormatTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifFormatTest.java index 544af9706..c75f05beb 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifFormatTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifFormatTest.java @@ -76,6 +76,55 @@ public void mfskSubmode_nullReturnsNull() { assertThat(AdifFormat.mfskSubmode(null)).isNull(); } + // ---- resolveImportMode: the reader-side inverse of mfskSubmode ---- + + @Test + public void resolveImportMode_mfskSubmodeBecomesTheSubmode() { + // The bug: an FT4/FT2 QSO is written as MODE=MFSK + SUBMODE=FT4; reading only + // MODE stored "MFSK", losing FT4/FT2 and breaking export->import round-trip. + assertThat(AdifFormat.resolveImportMode("MFSK", "FT4")).isEqualTo("FT4"); + assertThat(AdifFormat.resolveImportMode("MFSK", "FT2")).isEqualTo("FT2"); + } + + @Test + public void resolveImportMode_isCaseInsensitiveAndTrimmedAndUpperCased() { + assertThat(AdifFormat.resolveImportMode(" mfsk ", " ft4 ")).isEqualTo("FT4"); + assertThat(AdifFormat.resolveImportMode("Mfsk", "Ft2")).isEqualTo("FT2"); + } + + @Test + public void resolveImportMode_mfskWithoutSubmodeStaysMfsk() { + // Plain MFSK (no SUBMODE) is a real mode on its own; leave it untouched. + assertThat(AdifFormat.resolveImportMode("MFSK", null)).isEqualTo("MFSK"); + assertThat(AdifFormat.resolveImportMode("MFSK", "")).isEqualTo("MFSK"); + assertThat(AdifFormat.resolveImportMode("MFSK", " ")).isEqualTo("MFSK"); + } + + @Test + public void resolveImportMode_standaloneModesPassThroughVerbatim() { + // Non-MFSK modes are returned exactly as stored (no case change, no trim) so + // existing imports are byte-for-byte unchanged, even with a stray SUBMODE. + assertThat(AdifFormat.resolveImportMode("FT8", null)).isEqualTo("FT8"); + assertThat(AdifFormat.resolveImportMode("FT4", null)).isEqualTo("FT4"); // bare FT4 + assertThat(AdifFormat.resolveImportMode("SSB", "USB")).isEqualTo("SSB"); + assertThat(AdifFormat.resolveImportMode("CW", null)).isEqualTo("CW"); + } + + @Test + public void resolveImportMode_nullModeReturnedVerbatim() { + assertThat(AdifFormat.resolveImportMode(null, "FT4")).isNull(); + } + + @Test + public void resolveImportMode_isTheInverseOfMfskSubmode() { + // Every mode that exports as MODE=MFSK + SUBMODE must import back to itself. + for (String mode : new String[] { "FT4", "FT2" }) { + String submode = AdifFormat.mfskSubmode(mode); + assertThat(submode).isNotNull(); + assertThat(AdifFormat.resolveImportMode("MFSK", submode)).isEqualTo(mode); + } + } + @Test public void formatReport_alwaysSignedAndTwoDigits() { // The bug: bare String.valueOf(int) gave "5"/"-5"/"0" — no sign on positives, no padding. diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/log/QSLRecordTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/log/QSLRecordTest.java index 2a093a3d7..050d9c2bb 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/log/QSLRecordTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/log/QSLRecordTest.java @@ -95,6 +95,60 @@ public void mapConstructor_extractsCoreFields() { assertThat(r.getComment()).isEqualTo("imported"); } + @Test + public void mapConstructor_mfskSubmodeResolvesToFt4() { + // The bug: FT4/FT2 are exported as MODE=MFSK + SUBMODE=FT4 (both by FT8AF and + // by WSJT-X/JTDX/pota.app). Reading only MODE stored "MFSK", losing the FT4 + // distinction and breaking mode-keyed dedup, band/mode filtering and re-export. + HashMap map = new HashMap<>(); + map.put("CALL", "W1AW"); + map.put("COMMENT", "imported"); + map.put("MODE", "MFSK"); + map.put("SUBMODE", "FT4"); + + QSLRecord r = new QSLRecord(map); + + assertThat(r.getMode()).isEqualTo("FT4"); + } + + @Test + public void mapConstructor_mfskSubmodeResolvesToFt2() { + HashMap map = new HashMap<>(); + map.put("CALL", "W1AW"); + map.put("COMMENT", "imported"); + map.put("MODE", "MFSK"); + map.put("SUBMODE", "FT2"); + + QSLRecord r = new QSLRecord(map); + + assertThat(r.getMode()).isEqualTo("FT2"); + } + + @Test + public void mapConstructor_plainMfskWithoutSubmodeStaysMfsk() { + HashMap map = new HashMap<>(); + map.put("CALL", "W1AW"); + map.put("COMMENT", "imported"); + map.put("MODE", "MFSK"); + + QSLRecord r = new QSLRecord(map); + + assertThat(r.getMode()).isEqualTo("MFSK"); + } + + @Test + public void mapConstructor_ft8ModeUnaffectedBySubmodeResolution() { + // Regression guard: a first-class MODE (FT8) is stored verbatim. + HashMap map = new HashMap<>(); + map.put("CALL", "W1AW"); + map.put("COMMENT", "imported"); + map.put("MODE", "FT8"); + + QSLRecord r = new QSLRecord(map); + + assertThat(r.getMode()).isEqualTo("FT8"); + } + @Test public void mapConstructor_qslAndPotaFields() { HashMap map = new HashMap<>(); From 5354d19a5124813fbdf33f35743357f69f4979f9 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 15:03:25 -0500 Subject: [PATCH 052/113] Fix WaterfallView crash when the view is measured one block tall (h==1) (#660) Each new spectrum row scrolls the accumulated waterfall down by blockHeight and repaints the freed top strip. The scroll blit copies the region Bitmap.createBitmap(lastBitMap, 0, 0, drawWidth, drawHeight - blockHeight). When onSizeChanged sees h==1, blockHeight = 1/(symbols*cycle) clamps to 1, so drawHeight - blockHeight == 0 and createBitmap throws IllegalArgumentException ("height must be > 0"). setWaveData runs from the audio LiveData observer with no surrounding try/catch, so this is a whole-app crash. Extract scrolledRegionHeight(drawHeight, blockHeight) (clamped non-negative) and skip the scroll blit when it is 0 -- there is nothing above the new row to scroll, and the new-row paint still runs. In-pattern with the existing zero-dim guards in WaterfallView.onSizeChanged and ColumnarView. Adds WaterfallScrollGuardTest: pure geometry cases plus a Robolectric case that drives onSizeChanged(w,1)/setWaveData and reproduces the crash before the fix. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/ui/WaterfallView.java | 28 ++++++- .../ft8af/ui/WaterfallScrollGuardTest.java | 80 +++++++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/ui/WaterfallScrollGuardTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/WaterfallView.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/WaterfallView.java index 06f62e54e..5a277ab5d 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/WaterfallView.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/WaterfallView.java @@ -107,6 +107,19 @@ private int dpToPixel(int dp) { , getResources().getDisplayMetrics()); } + /** + * Height (px) of the existing waterfall region copied and scrolled down by one block on + * each new spectrum row. Clamped to be non-negative: when the view is only one block + * tall or shorter (e.g. measured at {@code h == 1}, where {@code blockHeight} clamps to + * 1 and equals {@code drawHeight}), there is nothing above the new row to scroll, so the + * caller must skip the {@code Bitmap.createBitmap} blit — that call throws + * {@code IllegalArgumentException} on a non-positive height. Extracted as a pure static + * helper so the geometry is unit-testable without a Canvas. + */ + static int scrolledRegionHeight(int drawHeight, int blockHeight) { + return Math.max(0, drawHeight - blockHeight); + } + @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { if (w <= 0 || h <= 0) return; @@ -301,9 +314,18 @@ public void setWaveData(int[] data, List msgs) { LinearGradient linearGradient = new LinearGradient(0, 0, drawWidth * gradientScale, 0, colors , null, Shader.TileMode.CLAMP); linearPaint.setShader(linearGradient); - Bitmap bitmap = Bitmap.createBitmap(lastBitMap, 0, 0, drawWidth, drawHeight - blockHeight); - _canvas.drawBitmap(bitmap, 0, blockHeight, null); - bitmap.recycle(); + // Scroll the previously-drawn waterfall down by one block, then paint the new + // spectrum row into the freed top strip. When the view is only a block tall (or + // shorter) there is nothing above the new row to scroll: scrolledRegionHeight() is + // 0 and we skip the blit — Bitmap.createBitmap rejects a non-positive height and + // would otherwise throw IllegalArgumentException (seen when the view is measured at + // h==1, where blockHeight clamps to 1 == drawHeight). The new-row paint still runs. + int scrolledHeight = scrolledRegionHeight(drawHeight, blockHeight); + if (scrolledHeight > 0) { + Bitmap bitmap = Bitmap.createBitmap(lastBitMap, 0, 0, drawWidth, scrolledHeight); + _canvas.drawBitmap(bitmap, 0, blockHeight, null); + bitmap.recycle(); + } _canvas.drawRect(0, 0, drawWidth, blockHeight, linearPaint); // Draw the UTC timestamp line at each slot boundary of the CURRENT mode (15s FT8, diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ui/WaterfallScrollGuardTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ui/WaterfallScrollGuardTest.java new file mode 100644 index 000000000..02e90f696 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ui/WaterfallScrollGuardTest.java @@ -0,0 +1,80 @@ +package com.k1af.ft8af.ui; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** + * Guards {@link WaterfallView} against the one-block-tall scroll crash. + * + *

Each new spectrum row scrolls the accumulated waterfall down by {@code blockHeight} + * and repaints the freed top strip. The scroll blit copies the region + * {@code Bitmap.createBitmap(lastBitMap, 0, 0, drawWidth, drawHeight - blockHeight)}. When + * the view is measured at {@code h == 1}, {@code onSizeChanged} clamps {@code blockHeight} + * to 1 (it would otherwise be {@code 1 / (symbols * cycle) == 0}), so + * {@code drawHeight - blockHeight == 0} and {@code createBitmap} throws + * {@code IllegalArgumentException} ("height must be > 0"). {@code setWaveData} runs from the + * audio LiveData observer with no surrounding try/catch, so this is a whole-app crash. + * + *

{@link WaterfallView#scrolledRegionHeight(int, int)} clamps the region height to a + * non-negative value and the caller skips the blit when it is 0. The pure cases below pin + * the geometry; the Robolectric case drives the real {@code onSizeChanged}/{@code + * setWaveData} path (Robolectric's {@code Bitmap} enforces the same preconditions the device + * does) and reproduces the crash before the fix. + */ +@RunWith(RobolectricTestRunner.class) +public class WaterfallScrollGuardTest { + + @Test + public void scrolledRegionHeight_oneBlockTall_isZero() { + // h == 1 -> blockHeight clamps to 1 -> nothing to scroll. + assertThat(WaterfallView.scrolledRegionHeight(1, 1)).isEqualTo(0); + } + + @Test + public void scrolledRegionHeight_shorterThanBlock_clampsToZero() { + // Defensive: never returns negative even if blockHeight somehow exceeds drawHeight. + assertThat(WaterfallView.scrolledRegionHeight(1, 2)).isEqualTo(0); + } + + @Test + public void scrolledRegionHeight_normalHeight_leavesRoomToScroll() { + // A normal-sized view: one block scrolls, the rest of the column is copied. + assertThat(WaterfallView.scrolledRegionHeight(400, 2)).isEqualTo(398); + } + + @Test + public void setWaveData_oneRowTallView_doesNotThrow() { + // Pre-fix: onSizeChanged(w, 1) leaves drawHeight == blockHeight == 1, and the first + // setWaveData scroll blit calls createBitmap(..., height=0) -> IllegalArgumentException. + Context context = ApplicationProvider.getApplicationContext(); + WaterfallView view = new WaterfallView(context); + view.onSizeChanged(200, 1, 0, 0); + + int[] data = new int[200]; + for (int i = 0; i < data.length; i++) { + data[i] = i % 256; + } + view.setWaveData(data, null); + } + + @Test + public void setWaveData_normalView_stillScrollsWithoutThrowing() { + // Happy path is unchanged: a real size still copies the scrolled region. + Context context = ApplicationProvider.getApplicationContext(); + WaterfallView view = new WaterfallView(context); + view.onSizeChanged(200, 400, 0, 0); + + int[] data = new int[200]; + for (int i = 0; i < data.length; i++) { + data[i] = i % 256; + } + view.setWaveData(data, null); + } +} From 0b30df6ac9b4d1a2eb0c6c67553a5fd74c9107c1 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 15:03:47 -0500 Subject: [PATCH 053/113] Fix ToastMessage crash on null debug message (Sentry FT8AF-3) (#655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix ToastMessage crash on null debug message (Sentry FT8AF-3) A null toast message (commonly ToastMessage.show(e.getMessage()) when Throwable.getMessage() returns null) was stored in debugList. The delayed cleanup runnable then iterated the list calling debugList.get(i).equals(info), which NPE'd on the null element and crashed the app ~5s after the toast. Root cause fix: treat a null message as a no-op (it never enters debugList, and no bogus "null" text is shown). The cleanup match/remove is also extracted to a null-safe, unit-testable helper (removeFirstMatch, via Objects.equals) as defence in depth. Co-Authored-By: Claude Opus 4.8 (1M context) * Make ToastMessage null-cleanup test environment-robust Replace the brittle LiveData.postValue value assertion (flaky across Robolectric looper configs) with an end-to-end guard that drives the main looper past the +5s cleanup delay and asserts no exception — this is the true fail-before-fix regression for the delayed-runnable NPE. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/ui/ToastMessage.java | 35 +++++-- .../com/k1af/ft8af/ui/ToastMessageTest.java | 97 +++++++++++++++++++ 2 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/ui/ToastMessageTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ToastMessage.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ToastMessage.java index e75e1d6ed..e58522c2e 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ToastMessage.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ToastMessage.java @@ -12,6 +12,8 @@ import com.k1af.ft8af.GeneralVariables; import java.util.ArrayList; +import java.util.List; +import java.util.Objects; public class ToastMessage { private static final String TAG="ToastMessage"; @@ -40,6 +42,13 @@ public static synchronized void show(String message,boolean clearMessage){ } @SuppressLint("DefaultLocale") private static synchronized void addDebugInfo(String s){ + if (s == null) { + // A null message (commonly ToastMessage.show(e.getMessage()) where + // Throwable.getMessage() returns null) must never enter debugList: + // the delayed cleanup runnable below matches entries via equals(), + // which would NPE on a null element. A null toast is a no-op. + return; + } if (debugList.size()>5){ //if (debugList.size()>20){ debugList.remove(0); @@ -51,12 +60,8 @@ private static synchronized void addDebugInfo(String s){ new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { @Override public void run() { - for (int i = 0; i list, String info){ + for (int i = 0; i < list.size(); i++) { + if (Objects.equals(list.get(i), info)) { + list.remove(i); + return true; + } + } + return false; + } private static synchronized String getDebugMessage(){ StringBuilder builder=new StringBuilder(); for (int i = 0; i list = new ArrayList<>(); + list.add("a"); + list.add("b"); + list.add("c"); + + assertThat(ToastMessage.removeFirstMatch(list, "b")).isTrue(); + assertThat(list).containsExactly("a", "c").inOrder(); + } + + @Test + public void removeFirstMatch_removesOnlyFirstMatch() { + List list = new ArrayList<>(); + list.add("dup"); + list.add("dup"); + + assertThat(ToastMessage.removeFirstMatch(list, "dup")).isTrue(); + assertThat(list).containsExactly("dup"); + } + + @Test + public void removeFirstMatch_noMatchReturnsFalse() { + List list = new ArrayList<>(); + list.add("a"); + + assertThat(ToastMessage.removeFirstMatch(list, "zzz")).isFalse(); + assertThat(list).containsExactly("a"); + } + + /** + * The regression: a null element in the list must not crash the match/remove + * scan. Before the fix, {@code list.get(i).equals(info)} threw NPE here. + */ + @Test + public void removeFirstMatch_nullElementInListDoesNotCrash() { + List list = new ArrayList<>(); + list.add(null); + list.add("real"); + + // Scanning past the null element to find "real" must not NPE. + assertThat(ToastMessage.removeFirstMatch(list, "real")).isTrue(); + assertThat(list).containsExactly((Object) null); + } + + @Test + public void removeFirstMatch_nullInfoRemovesNullElement() { + List list = new ArrayList<>(); + list.add("real"); + list.add(null); + + assertThat(ToastMessage.removeFirstMatch(list, null)).isTrue(); + assertThat(list).containsExactly("real"); + } + + /** + * End-to-end regression guard: before the fix, {@code show(null)} stored a + * null in debugList and scheduled a +5 s cleanup runnable that NPE'd on + * {@code debugList.get(i).equals(info)}. Driving the main looper past the + * cleanup delay must not throw. + */ + @Test + public void show_nullMessageCleanupDoesNotCrash() { + ToastMessage.show(null); + ToastMessage.show("visible"); + + // Run every delayed cleanup runnable (5 s each) on the paused main looper. + shadowOf(Looper.getMainLooper()).idleFor(6, TimeUnit.SECONDS); + + // Reaching here without an exception is the assertion. + } +} From 89933039c7c309fab8f5663dcd1a06ac300d1edc Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 15:04:14 -0500 Subject: [PATCH 054/113] Fix XieGu X6100 CI-V frequency decode: weight the GHz BCD digit correctly (#654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XieGu6100Command.getFrequency() mirrors IcomCommand.getFrequency() (both decode a 5-byte little-endian BCD Icom CI-V frequency), but its most- significant term multiplied the 1 GHz digit by 100000000 (10^8) instead of 1000000000 (10^9) — a copy/paste of the adjacent 100 MHz multiplier — and did the whole assembly in int arithmetic. IcomCommand uses a 1000000000L long literal for exactly this reason. Effect: the top BCD digit is dropped by an order of magnitude, and a value above Integer.MAX_VALUE overflows mid-sum. For the X6100's HF/6m range the GHz digit is always 0, so the live decode is unaffected today, but the shared BCD decoder is simply wrong for any CI-V frequency >= 1 GHz and diverges from its audited IcomCommand twin. Fix: weight the GHz digit by 1000000000L, matching IcomCommand.getFrequency (the single change also keeps the sum in long). Added two unit tests (1.296 GHz and 2.4 GHz > int max), both of which fail before the fix. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/rigs/XieGu6100Command.java | 2 +- .../k1af/ft8af/rigs/XieGu6100CommandTest.java | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/XieGu6100Command.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/XieGu6100Command.java index a885c230a..e16935919 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/XieGu6100Command.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/XieGu6100Command.java @@ -229,7 +229,7 @@ public long getFrequency(boolean hasSubCommand) { + (int) (data[3] & 0x0f) * 1000000//millions 1MHz + ((int) (data[3] >> 4) & 0xf) * 10000000//ten-millions 10MHz + (int) (data[4] & 0x0f) * 100000000//hundred-millions 100MHz - + ((int) (data[4] >> 4) & 0xf) * 100000000;//billions 1GHz + + ((int) (data[4] >> 4) & 0xf) * 1000000000L;//billions 1GHz (long: keeps the sum from overflowing int, matches IcomCommand.getFrequency) } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/XieGu6100CommandTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/XieGu6100CommandTest.java index fa0cbc792..16779f74f 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/XieGu6100CommandTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/XieGu6100CommandTest.java @@ -64,6 +64,45 @@ public void getFrequency_decodesLittleEndianBcd() { assertThat(c.getFrequency(false)).isEqualTo(14_074_000L); } + /** Build a read-frequency reply carrying the 5 little-endian BCD frequency bytes. */ + private static byte[] freqFrame(int b0, int b1, int b2, int b3, int b4) { + return new byte[]{ + (byte) 0xFE, (byte) 0xFE, (byte) CTRL, (byte) RIG, + (byte) 0x03, + (byte) b0, (byte) b1, (byte) b2, (byte) b3, (byte) b4, + (byte) 0xFD}; + } + + /** + * The most-significant BCD digit is the 1 GHz place and must be weighted 10^9, + * matching {@link IcomCommand#getFrequency}. Weighting it 10^8 (a copy/paste of + * the 100 MHz multiplier) drops that digit by an order of magnitude — e.g. + * 1.296 GHz would decode as 0.396 GHz. + */ + @Test + public void getFrequency_weightsGigahertzDigitCorrectly() { + // 1,296,074,000 Hz -> BCD 00 40 07 96 12 (GHz digit = 1) + XieGu6100Command c = XieGu6100Command.getCommand(CTRL, RIG, + freqFrame(0x00, 0x40, 0x07, 0x96, 0x12)); + assertThat(c).isNotNull(); + assertThat(c.getFrequency(false)).isEqualTo(1_296_074_000L); + } + + /** + * A frequency above {@link Integer#MAX_VALUE} must survive the BCD assembly: the + * decoder returns {@code long}, so the arithmetic has to stay in {@code long} + * rather than overflowing an {@code int} partway through (again mirroring + * {@link IcomCommand#getFrequency}, whose top term is a {@code long} literal). + */ + @Test + public void getFrequency_doesNotOverflowIntAboveTwoGigahertz() { + // 2,400,000,000 Hz -> BCD 00 00 00 00 24 (> Integer.MAX_VALUE) + XieGu6100Command c = XieGu6100Command.getCommand(CTRL, RIG, + freqFrame(0x00, 0x00, 0x00, 0x00, 0x24)); + assertThat(c).isNotNull(); + assertThat(c.getFrequency(false)).isEqualTo(2_400_000_000L); + } + @Test public void readShortData_bigEndianAssembly() { assertThat(XieGu6100Command.readShortData(new byte[]{(byte) 0x12, (byte) 0x34}, 0)) From 042834d6486b0ec97fb05fcd5b542a83941691ad Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 15:37:47 -0500 Subject: [PATCH 055/113] Fix FT-891 CAT: only the first of two coalesced replies was parsed (SWR lost, next parse poisoned) (#665) Yaesu39Rig.onReceiveData (FT-891) parsed only the FIRST ';'-terminated command in a serial/USB read and pushed the remainder -- including its retained ';' and any further complete commands -- back into the buffer. So when the rig answered two back-to-back polls in one read, the second reply was never parsed this call, and the retained ';' then poisoned the next read's parse (the stale command ID was read as the command and the real command landed inside the data field and was discarded). This happens in normal operation: readMeters() sends the ALC and SWR requests one after the other (setRead39Meters_ALC() then setRead39Meters_SWR()), so the rig replies "RM4nnn;RM6nnn;" and the two frames routinely coalesce into one read. The first (ALC) was parsed; the second (SWR) sat unparsed in the buffer and was then discarded by the next poll's clearBufferData() -- so SWR read stale during TX, exactly when SWR protection matters. The same happens for coalesced FA (frequency) replies under auto-information, leaving the displayed frequency lagging or stuck on a stale value. Root cause: no loop to drain every complete, terminated command from the buffer in one call, and the remainder was re-buffered with its terminator retained. The binary CI-V rigs (Icom/Xiegu) were already migrated to a shared reassembler (CivFrameSplitter) that fixes exactly this class of bug; the string-terminator rigs never got the equivalent. Fix: add CatLineSplitter -- the text-terminator sibling of CivFrameSplitter, a pure, unit-testable helper that appends the incoming bytes to the buffered remainder and splits the result into every complete command (terminator stripped) plus a trailing remainder. Yaesu39Rig now drains all commands per read and carries only the unterminated tail. Per-command handling is unchanged (extracted verbatim into processCommand); a latent double getFrequency() call is collapsed to reuse the already-computed value. Testing: new CatLineSplitterTest (10 cases, pure JVM) pins the reassembly contract and locks the regression -- two commands in one read yield two frames, a whole command in the carry-over buffer plus a fresh command yield two frames (never a poisoned concatenation), split-across-reads reassembles, terminator is stripped, empty/leading-terminator frames are preserved. Full :app:testDebugUnitTest green. Scope: this PR fixes the FT-891 (Yaesu39Rig) only. The same one-command-per-read pattern exists in the other string-terminator handlers (Yaesu38/38_450/DX10, Elecraft, Kenwood TS570/590/2000, Flex6000, Wolf_sdr_450); CatLineSplitter is terminator-parameterised so those can adopt it in focused follow-ups. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/rigs/CatLineSplitter.java | 75 +++++++++++++ .../java/com/k1af/ft8af/rigs/Yaesu39Rig.java | 84 ++++++++------- .../k1af/ft8af/rigs/CatLineSplitterTest.java | 101 ++++++++++++++++++ 3 files changed, 220 insertions(+), 40 deletions(-) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/rigs/CatLineSplitter.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/CatLineSplitter.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/CatLineSplitter.java new file mode 100644 index 000000000..a261c3102 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/CatLineSplitter.java @@ -0,0 +1,75 @@ +package com.k1af.ft8af.rigs; + +import java.util.ArrayList; +import java.util.List; + +/** + * Reassembles a text-framed CAT stream (Yaesu / Kenwood / Elecraft / Flex-6000 + * class rigs) into whole commands. + * + *

These rigs frame every reply as {@code } — e.g. + * {@code FA014074000;} or {@code RM6020;} — and the serial / USB / Bluetooth + * transport delivers bytes in arbitrary chunks: a single read may carry only + * part of a command, several whole commands (the rig commonly answers two + * back-to-back polls — the meter poll deliberately sends the ALC and SWR + * requests one after the other — so both replies arrive in one read), or a whole + * command followed by the start of the next. Callers feed each received chunk + * together with the bytes left over from the previous chunk; this groups them + * into complete commands (each up to, but excluding, a {@code terminator}) plus a + * trailing remainder to carry into the next call. + * + *

Pure logic — no rig or Android state — so it is unit-testable, and it is the + * text-terminator sibling of {@link CivFrameSplitter} (which does the same job + * for the binary CI-V protocol). Each string-terminator rig previously carried + * its own reassembly that parsed only the first command in a read and + * pushed the untouched remainder — including its retained terminator and + * any further complete commands — back into the buffer. That dropped the second + * (and later) command of a coalesced read, and the retained terminator then + * poisoned the parse of the next read (the stale {@code ID} was parsed as the + * command and the real command landed inside the data field and was discarded). + */ +final class CatLineSplitter { + private CatLineSplitter() { + } + + /** Result of {@link #split}: the complete commands and the trailing remainder. */ + static final class Result { + /** Complete commands, terminator stripped, in arrival order. */ + final List frames; + /** Bytes after the last terminator — an incomplete command to buffer. */ + final String remainder; + + Result(List frames, String remainder) { + this.frames = frames; + this.remainder = remainder; + } + } + + /** + * Append {@code incoming} to {@code buffered} and split the result into whole + * commands plus a trailing remainder. + * + *

Every complete command is returned, so a read that coalesces several + * replies yields several frames rather than one; the terminator is stripped + * from each frame (matching the pre-existing parse, which fed the command + * without its terminator). An empty frame (two adjacent terminators, or a + * leading terminator) is preserved so callers see it and treat it as an + * unknown/no-op command exactly as before. + * + * @param buffered bytes left over from the previous call (never null; may be empty) + * @param incoming newly received bytes (never null; may be empty) + * @param terminator the end-of-command character (e.g. {@code ';'}) + * @return the complete commands and the bytes to carry into the next call + */ + static Result split(String buffered, String incoming, char terminator) { + String combined = buffered + incoming; + List frames = new ArrayList<>(); + int start = 0; + int idx; + while ((idx = combined.indexOf(terminator, start)) >= 0) { + frames.add(combined.substring(start, idx)); + start = idx + 1; + } + return new Result(frames, combined.substring(start)); + } +} diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu39Rig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu39Rig.java index 530b67249..5b3f66872 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu39Rig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu39Rig.java @@ -179,50 +179,54 @@ public void onReceiveData(byte[] data) { fileLog("rig.recv[" + data.length + "]: " + preview); } - if (!s.contains(";")) { - buffer.append(s); - if (buffer.length() > 1000) clearBufferData(); - // return;//data reception not yet complete. - } else { - if (s.indexOf(";") > 0) {//received end-of-data, and delimiter is not the first character - buffer.append(s.substring(0, s.indexOf(";"))); - } - - //begin parsing data - String bufStr = buffer.toString(); - Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(bufStr); - clearBufferData();//clear buffer - //put remaining data into buffer - buffer.append(s.substring(s.indexOf(";") + 1)); + // Drain every complete ';'-terminated command in this read (the rig + // answers back-to-back polls -- the meter poll sends the ALC and SWR + // requests one after the other -- so two replies routinely coalesce into + // one read). The old code parsed only the first command and re-buffered + // the rest with its terminator retained, dropping the second reply and + // poisoning the next parse. Only the trailing, unterminated fragment is + // carried into the next call. + CatLineSplitter.Result result = CatLineSplitter.split(buffer.toString(), s, ';'); + clearBufferData(); + buffer.append(result.remainder); + // A runaway unterminated buffer must not grow without bound. + if (buffer.length() > 1000) clearBufferData(); + + for (String frame : result.frames) { + processCommand(frame); + } + } - if (yaesu3Command == null) { - if (bufStr.length() > 0) { - fileLog("rig.parse: unknown command: " + bufStr); - } - return; + /** + * Parse and act on one complete CAT command (terminator already stripped). + */ + private void processCommand(String bufStr) { + Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(bufStr); + if (yaesu3Command == null) { + if (bufStr.length() > 0) { + fileLog("rig.parse: unknown command: " + bufStr); } - if (yaesu3Command.getCommandID().equalsIgnoreCase("FA") - || yaesu3Command.getCommandID().equalsIgnoreCase("FB")) { - long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); - if (tempFreq != 0) {//if tempFreq==0, frequency is invalid - setFreq(Yaesu3Command.getFrequency(yaesu3Command)); - } - } else if (yaesu3Command.getCommandID().equalsIgnoreCase("RM")) {//METER - if (Yaesu3Command.isSWRMeter39(yaesu3Command)) { - swr = Yaesu3Command.getSWROrALC39(yaesu3Command); - } - if (Yaesu3Command.isALCMeter39(yaesu3Command)) { - alc = Yaesu3Command.getSWROrALC39(yaesu3Command); - } - showAlert(); - notifyMeterData(alc, swr); - } else { - fileLog("rig.parsed: cmd=" + yaesu3Command.getCommandID() - + " data=" + yaesu3Command.getData()); + return; + } + if (yaesu3Command.getCommandID().equalsIgnoreCase("FA") + || yaesu3Command.getCommandID().equalsIgnoreCase("FB")) { + long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); + if (tempFreq != 0) {//if tempFreq==0, frequency is invalid + setFreq(tempFreq); } - + } else if (yaesu3Command.getCommandID().equalsIgnoreCase("RM")) {//METER + if (Yaesu3Command.isSWRMeter39(yaesu3Command)) { + swr = Yaesu3Command.getSWROrALC39(yaesu3Command); + } + if (Yaesu3Command.isALCMeter39(yaesu3Command)) { + alc = Yaesu3Command.getSWROrALC39(yaesu3Command); + } + showAlert(); + notifyMeterData(alc, swr); + } else { + fileLog("rig.parsed: cmd=" + yaesu3Command.getCommandID() + + " data=" + yaesu3Command.getData()); } - } @Override diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java new file mode 100644 index 000000000..ed119cc68 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java @@ -0,0 +1,101 @@ +package com.k1af.ft8af.rigs; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Pure-logic coverage for {@link CatLineSplitter}, the shared text-terminator + * (Yaesu / Kenwood / Elecraft / Flex-6000) CAT stream reassembler. + * + *

These rigs frame commands as {@code ;} and the transport delivers + * bytes in arbitrary chunks, so a command can span several callbacks or several + * commands can arrive in one read. These tests pin the reassembly contract and + * lock in the regression the old per-rig reassembly had: it parsed only the + * first command in a read and re-buffered the remainder with its + * retained terminator, so a coalesced second reply (routine for the ALC+SWR meter + * poll) was dropped and the retained {@code ';'} then poisoned the next parse. + * + *

No Android types are touched, so no Robolectric runner is needed. + */ +public class CatLineSplitterTest { + + @Test + public void singleCompleteCommand_oneFrameNoRemainder() { + CatLineSplitter.Result r = CatLineSplitter.split("", "FA014074000;", ';'); + assertThat(r.frames).containsExactly("FA014074000"); + assertThat(r.remainder).isEmpty(); + } + + @Test + public void twoCommandsInOneRead_bothReturned() { + // The regression: the ALC and SWR meter replies coalesce into one read. + CatLineSplitter.Result r = CatLineSplitter.split("", "RM4100;RM6020;", ';'); + assertThat(r.frames).containsExactly("RM4100", "RM6020").inOrder(); + assertThat(r.remainder).isEmpty(); + } + + @Test + public void terminatorStrippedFromEveryFrame() { + CatLineSplitter.Result r = CatLineSplitter.split("", "FA1;FB2;", ';'); + for (String frame : r.frames) { + assertThat(frame).doesNotContain(";"); + } + } + + @Test + public void incompleteCommand_noFramesCarriedAsRemainder() { + CatLineSplitter.Result r = CatLineSplitter.split("", "FA0140", ';'); + assertThat(r.frames).isEmpty(); + assertThat(r.remainder).isEqualTo("FA0140"); + } + + @Test + public void commandSplitAcrossTwoReads_reassembledFromBufferedRemainder() { + CatLineSplitter.Result first = CatLineSplitter.split("", "FA0140", ';'); + assertThat(first.frames).isEmpty(); + + CatLineSplitter.Result second = CatLineSplitter.split(first.remainder, "74000;", ';'); + assertThat(second.frames).containsExactly("FA014074000"); + assertThat(second.remainder).isEmpty(); + } + + @Test + public void completeCommandFollowedByStartOfNext_remainderCarried() { + CatLineSplitter.Result r = CatLineSplitter.split("", "FA014074000;RM6", ';'); + assertThat(r.frames).containsExactly("FA014074000"); + assertThat(r.remainder).isEqualTo("RM6"); + } + + @Test + public void bufferedRemainderPrependedBeforeSplitting() { + // A whole command sitting in the carry-over buffer plus a fresh terminated + // command must yield two frames, never a single poisoned concatenation. + CatLineSplitter.Result r = CatLineSplitter.split("RM6020;", "FA014076000;", ';'); + assertThat(r.frames).containsExactly("RM6020", "FA014076000").inOrder(); + assertThat(r.remainder).isEmpty(); + } + + @Test + public void emptyFrames_preservedForAdjacentAndLeadingTerminators() { + CatLineSplitter.Result r = CatLineSplitter.split("", ";FA1;;", ';'); + assertThat(r.frames).containsExactly("", "FA1", "").inOrder(); + assertThat(r.remainder).isEmpty(); + } + + @Test + public void noInputAtAll_noFramesEmptyRemainder() { + CatLineSplitter.Result r = CatLineSplitter.split("", "", ';'); + assertThat(r.frames).isEmpty(); + assertThat(r.remainder).isEmpty(); + } + + @Test + public void carriageReturnTerminator_supportedForSiblingRigs() { + // The splitter is terminator-parameterised so the Kenwood/Elecraft '\r' + // handlers can adopt it too. + CatLineSplitter.Result r = CatLineSplitter.split("", "IF00014074000\rFA1\r", '\r'); + assertThat(r.frames).containsExactly("IF00014074000", "FA1").inOrder(); + assertThat(r.remainder).isEmpty(); + } +} From a000b81b57992e3ef3341aca287d46be30e240f7 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 15:38:24 -0500 Subject: [PATCH 056/113] Add a "Distance (DX first)" sort mode to the decode list (#664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a 'Distance (DX first)' sort mode to the decode list DX hunters scanning a busy band want to spot the farthest station they can currently decode at a glance. The decode list already cycles between TIME / CALL / SNR sorts; this adds a fourth, DISTANCE, that orders the one-row-per-station list by great-circle distance from the operator's grid, farthest first. Stations whose decode carried no usable grid (or when the operator's own grid is unset) have an unknown distance and sink to the bottom, mirroring how unknown-SNR rows are handled — they're never dumped in among the DX. The sort button now cycles TIME -> CALL -> SNR -> DX -> TIME, labelled "DX", and the persisted decodeSortMode config round-trips the new value. collapseByStation takes an injectable distance provider so the ordering logic is unit-tested without Play-Services grid math; gridDistanceKm is covered directly under Robolectric. Co-Authored-By: Claude Opus 4.8 (1M context) * Cover the live stationDistanceKm distance provider Adds Robolectric tests exercising stationDistanceKm against GeneralVariables' operator grid: matches the direct grid-to-grid distance, and returns null when the operator grid is unset or the decode carried no grid. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../ks3ckc/ft8af/ui/decode/DecodeCollapse.kt | 51 ++++++- .../ks3ckc/ft8af/ui/decode/DecodeScreen.kt | 4 +- .../src/main/res/values/strings_compose.xml | 2 + .../ft8af/ui/decode/DecodeCollapseTest.kt | 131 +++++++++++++++++- 4 files changed, 185 insertions(+), 3 deletions(-) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapse.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapse.kt index d3fd6b084..45ee930db 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapse.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapse.kt @@ -1,6 +1,8 @@ package radio.ks3ckc.ft8af.ui.decode import com.k1af.ft8af.Ft8Message +import com.k1af.ft8af.GeneralVariables +import com.k1af.ft8af.maidenhead.MaidenheadGrid /** * How the collapsed (one-row-per-station) decode list is ordered. @@ -19,6 +21,9 @@ internal enum class DecodeSortMode(val configValue: Int) { /** Strongest signal (highest SNR) first. */ SNR(2), + + /** Farthest station first — surfaces the best DX on the band at a glance. */ + DISTANCE(3), ; companion object { @@ -32,7 +37,8 @@ internal enum class DecodeSortMode(val configValue: Int) { internal fun nextSortMode(current: DecodeSortMode): DecodeSortMode = when (current) { DecodeSortMode.LAST_HEARD -> DecodeSortMode.CALLSIGN DecodeSortMode.CALLSIGN -> DecodeSortMode.SNR - DecodeSortMode.SNR -> DecodeSortMode.LAST_HEARD + DecodeSortMode.SNR -> DecodeSortMode.DISTANCE + DecodeSortMode.DISTANCE -> DecodeSortMode.LAST_HEARD } /** @@ -49,10 +55,16 @@ internal fun nextSortMode(current: DecodeSortMode): DecodeSortMode = when (curre * * Callers should collapse **after** filtering so the visible-station count * agrees with the active chip filter (see `filterMessages`). + * + * [distanceKm] resolves the great-circle distance (km) from the operator to a + * station for [DecodeSortMode.DISTANCE]; it defaults to the live + * [stationDistanceKm] but is injectable so the sort ordering can be unit-tested + * without Android/Play-Services grid math. It is only invoked in DISTANCE mode. */ internal fun collapseByStation( messages: List, sortMode: DecodeSortMode, + distanceKm: (Ft8Message) -> Double? = ::stationDistanceKm, ): List { // LinkedHashMap preserves first-appearance order, which is our stable // tiebreak. Overwrite with the newer decode (>= keeps later-on-tie). @@ -73,9 +85,46 @@ internal fun collapseByStation( DecodeSortMode.SNR -> collapsed.sortedByDescending { if (it.hasSnr()) it.snr else Int.MIN_VALUE } + // Farthest first. A station with no usable grid has an unknown distance + // and sinks to the bottom (like unknown SNR) rather than pretending to be + // near. Distance is resolved once per station up front so the comparator + // doesn't recompute the (grid → LatLng → great-circle) math repeatedly. + DecodeSortMode.DISTANCE -> + collapsed + .map { it to (distanceKm(it) ?: Double.NEGATIVE_INFINITY) } + .sortedByDescending { it.second } + .map { it.first } } } +/** + * Great-circle distance in km from grid [myGrid] to grid [theirGrid], or null + * when either grid is missing or can't be parsed to a location. Kept as a plain + * function (mirroring `computeDistanceText`) so it's directly unit-testable with + * explicit grids under Robolectric. + * + * Identical, parseable grids yield a real 0.0 (the closest possible), which is + * distinct from the null "unknown distance" used to sink grid-less stations. + */ +internal fun gridDistanceKm(myGrid: String?, theirGrid: String?): Double? { + if (myGrid.isNullOrEmpty() || theirGrid.isNullOrEmpty()) return null + return try { + val mine = MaidenheadGrid.gridToLatLng(myGrid) ?: return null + val theirs = MaidenheadGrid.gridToLatLng(theirGrid) ?: return null + MaidenheadGrid.getDist(mine, theirs) + } catch (_: Exception) { + null + } +} + +/** + * Live distance provider for [DecodeSortMode.DISTANCE]: the km from the + * operator's configured grid to [message]'s sender grid, or null when the + * distance can't be computed (no operator grid, or the decode carried no grid). + */ +internal fun stationDistanceKm(message: Ft8Message): Double? = + gridDistanceKm(GeneralVariables.getMyMaidenheadGrid(), message.maidenGrid) + /** * The station identity a decode collapses onto: [Ft8Message.callsignFrom] trimmed * and upper-cased, or null when there is nothing to coalesce on. diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt index fc2e21950..062f8a8e9 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt @@ -476,7 +476,7 @@ private fun TimeGroupDivider(utcTime: Long, compact: Boolean = false) { /** * Compact label rendered inside the top-bar sort button, showing the active - * [DecodeSortMode] (TIME / CALL / SNR). The button cycles modes on tap; the + * [DecodeSortMode] (TIME / CALL / SNR / DX). The button cycles modes on tap; the * accessibility content description announces the current mode. */ @Composable @@ -486,6 +486,7 @@ internal fun SortModeLabel(sortMode: DecodeSortMode) { DecodeSortMode.LAST_HEARD -> R.string.decode_sort_label_last_heard DecodeSortMode.CALLSIGN -> R.string.decode_sort_label_callsign DecodeSortMode.SNR -> R.string.decode_sort_label_snr + DecodeSortMode.DISTANCE -> R.string.decode_sort_label_distance }, ) val cd = stringResource( @@ -493,6 +494,7 @@ internal fun SortModeLabel(sortMode: DecodeSortMode) { DecodeSortMode.LAST_HEARD -> R.string.decode_sort_cd_last_heard DecodeSortMode.CALLSIGN -> R.string.decode_sort_cd_callsign DecodeSortMode.SNR -> R.string.decode_sort_cd_snr + DecodeSortMode.DISTANCE -> R.string.decode_sort_cd_distance }, ) Text( diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 1eab0d455..ef33733af 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -165,9 +165,11 @@ TIME CALL SNR + DX Sort: last heard Sort: callsign Sort: SNR + Sort: distance, farthest first All CQ Calls CQ POTA diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapseTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapseTest.kt index 929eed38e..f8f449ba6 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapseTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapseTest.kt @@ -2,6 +2,7 @@ package radio.ks3ckc.ft8af.ui.decode import com.google.common.truth.Truth.assertThat import com.k1af.ft8af.Ft8Message +import com.k1af.ft8af.GeneralVariables import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -242,7 +243,8 @@ class DecodeCollapseTest { fun nextSortMode_cyclesThroughAllThenWraps() { assertThat(nextSortMode(DecodeSortMode.LAST_HEARD)).isEqualTo(DecodeSortMode.CALLSIGN) assertThat(nextSortMode(DecodeSortMode.CALLSIGN)).isEqualTo(DecodeSortMode.SNR) - assertThat(nextSortMode(DecodeSortMode.SNR)).isEqualTo(DecodeSortMode.LAST_HEARD) + assertThat(nextSortMode(DecodeSortMode.SNR)).isEqualTo(DecodeSortMode.DISTANCE) + assertThat(nextSortMode(DecodeSortMode.DISTANCE)).isEqualTo(DecodeSortMode.LAST_HEARD) } @Test @@ -250,6 +252,133 @@ class DecodeCollapseTest { assertThat(showTimeGroupDividers(DecodeSortMode.LAST_HEARD)).isTrue() assertThat(showTimeGroupDividers(DecodeSortMode.CALLSIGN)).isFalse() assertThat(showTimeGroupDividers(DecodeSortMode.SNR)).isFalse() + assertThat(showTimeGroupDividers(DecodeSortMode.DISTANCE)).isFalse() + } + + // ---- distance sort ------------------------------------------------------- + + @Test + fun sort_distance_farthestFirst_unknownSinks() { + // Distance is injected so the ordering logic is exercised without any + // grid math. Farthest station first; a null (unknown) distance sinks last. + val messages = listOf( + msg("NEAR", utc = 1000), + msg("FAR", utc = 1000), + msg("UNK", utc = 1000), + msg("MID", utc = 1000), + ) + val distances = mapOf( + "NEAR" to 200.0, + "FAR" to 9000.0, + "UNK" to null, + "MID" to 3000.0, + ) + + val result = collapseByStation(messages, DecodeSortMode.DISTANCE) { + distances[it.callsignFrom] + } + + assertThat(result.map { it.callsignFrom }) + .containsExactly("FAR", "MID", "NEAR", "UNK").inOrder() + } + + @Test + fun sort_distance_stableOnTiedDistance() { + val messages = listOf( + msg("AA", utc = 1000), + msg("BB", utc = 2000), + msg("CC", utc = 3000), + ) + + val result = collapseByStation(messages, DecodeSortMode.DISTANCE) { 5000.0 } + + // Equal distances keep first-appearance order (stable sort). + assertThat(result.map { it.callsignFrom }).containsExactly("AA", "BB", "CC").inOrder() + } + + @Test + fun sort_distance_allUnknownKeepsFirstAppearanceOrder() { + val messages = listOf( + msg("AA", utc = 3000), + msg("BB", utc = 1000), + msg("CC", utc = 2000), + ) + + val result = collapseByStation(messages, DecodeSortMode.DISTANCE) { null } + + assertThat(result.map { it.callsignFrom }).containsExactly("AA", "BB", "CC").inOrder() + } + + @Test + fun sort_distance_collapsesToLatestDecodeBeforeSorting() { + // A station re-decoded closer/farther still collapses to one row keyed on + // the latest decode, then sorts by that row's distance. + val messages = listOf( + msg("K1ABC", utc = 1000), + msg("W9XYZ", utc = 1100), + msg("K1ABC", utc = 2000), + ) + val distances = mapOf("K1ABC" to 8000.0, "W9XYZ" to 1000.0) + + val result = collapseByStation(messages, DecodeSortMode.DISTANCE) { + distances[it.callsignFrom] + } + + assertThat(result.map { it.callsignFrom }).containsExactly("K1ABC", "W9XYZ").inOrder() + assertThat(result.first { it.callsignFrom == "K1ABC" }.utcTime).isEqualTo(2000) + } + + // ---- gridDistanceKm (live distance provider) ----------------------------- + + @Test + fun gridDistanceKm_nullWhenEitherGridMissingOrUnparseable() { + assertThat(gridDistanceKm(null, "FN42")).isNull() + assertThat(gridDistanceKm("FN42", null)).isNull() + assertThat(gridDistanceKm("", "FN42")).isNull() + assertThat(gridDistanceKm("FN42", "")).isNull() + // "ABC" is an unsupported grid length -> unparseable -> unknown distance. + assertThat(gridDistanceKm("FN42", "ABC")).isNull() + } + + @Test + fun gridDistanceKm_identicalGridsIsZeroNotNull() { + // A real, computable ~0 (closest possible, modulo great-circle rounding) + // must stay distinct from the null used to sink grid-less stations. + val d = gridDistanceKm("FN42", "FN42") + assertThat(d).isNotNull() + assertThat(d!!).isWithin(1.0).of(0.0) + } + + @Test + fun gridDistanceKm_distinctGridsIsPositive_andFartherIsLarger() { + // FN42 (New England) -> IO91 (England) is a few thousand km; -> RE78 + // (New Zealand) is much farther. The order must reflect real distance. + val toEngland = gridDistanceKm("FN42", "IO91")!! + val toNewZealand = gridDistanceKm("FN42", "RE78")!! + assertThat(toEngland).isGreaterThan(0.0) + assertThat(toNewZealand).isGreaterThan(toEngland) + } + + // ---- stationDistanceKm (live provider off GeneralVariables) --------------- + + @Test + fun stationDistanceKm_usesOperatorGridAndMessageGrid() { + GeneralVariables.setMyMaidenheadGrid("FN42") + // Sanity: matches computing straight from the two grids. + val expected = gridDistanceKm("FN42", "IO91")!! + assertThat(stationDistanceKm(msg("G4ABC", utc = 1000).apply { maidenGrid = "IO91" })) + .isWithin(0.001).of(expected) + } + + @Test + fun stationDistanceKm_nullWhenOperatorGridUnsetOrMessageHasNoGrid() { + GeneralVariables.setMyMaidenheadGrid("") + assertThat(stationDistanceKm(msg("G4ABC", utc = 1000).apply { maidenGrid = "IO91" })) + .isNull() + + GeneralVariables.setMyMaidenheadGrid("FN42") + assertThat(stationDistanceKm(msg("G4ABC", utc = 1000).apply { maidenGrid = null })) + .isNull() } @Test From 735634054ce0bf9191276cbb4d13a691e141e6af Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 16:15:39 -0500 Subject: [PATCH 057/113] Bound CI-V reassembly buffer to prevent OOM on a terminator-less stream (#649) * Bound CI-V reassembly buffer to prevent OOM on a terminator-less stream (#649) CivFrameSplitter carried every byte after the last 0xFD terminator into the next read as the remainder, with no upper bound. A CI-V stream that never delivers a terminator -- a rig on the wrong baud, serial line noise, or a misbehaving network peer streaming garbage -- grew each rig's dataBuffer by every received byte until the app ran out of memory. This hit all CI-V rigs (IcomRig, XieGuRig, XieGu6100Rig), which share this reassembler and store result.remainder back into dataBuffer. Bound the remainder in the one shared place it is produced. When it exceeds a hard cap (1024 bytes, far above any real CI-V command), resynchronise to the most recent FE FE preamble -- the earliest point a valid command could still begin -- dropping the unparseable bytes before it (they can never complete a frame, since a valid one would have ended in 0xFD and already been split off). With no preamble present, keep at most a trailing 0xFE in case one is split across the read boundary; a final trailing-window clamp guarantees boundedness in the pathological lone-preamble case. Behaviour is byte-for-byte identical for all well-formed and normally-fragmented traffic. Added pure-JVM CivFrameSplitterTest cases: a terminator-less stream stays bounded across 100 callbacks (fails before the fix), overflow resyncs to the last preamble and the frame still completes on the next chunk, no-preamble overflow keeps only a trailing 0xFE, pure noise is dropped, a lone preamble with a huge tail is hard-capped, and a large-but-under-cap partial is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * Address review: avoid oversized intermediate copy in boundRemainder Compute the resync copy start first and copy at most MAX_BUFFERED_BYTES bytes in one shot, rather than allocating a potentially huge intermediate array from the early FE FE preamble only to trim it back down. Behaviour is byte-identical to the previous two-branch form; the guard fires precisely in the malformed-stream case this bound exists to handle, where the extra allocation would spike peak memory the most. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/rigs/CivFrameSplitter.java | 57 +++++++++- .../k1af/ft8af/rigs/CivFrameSplitterTest.java | 104 ++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/CivFrameSplitter.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/CivFrameSplitter.java index 582fa978b..059412c3f 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/CivFrameSplitter.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/CivFrameSplitter.java @@ -23,10 +23,28 @@ * every earlier fragment of a split command, and (b) appended two stray * {@code 0x00} bytes to the carried-over remainder after every frame, corrupting * the next command when it was reassembled from more than one chunk. + * + *

The carried-over remainder is also bounded: a stream that never delivers the + * {@code 0xFD} terminator (a rig on the wrong baud, line noise, a misbehaving + * network peer) would otherwise grow it without limit and eventually exhaust + * memory. See {@link #boundRemainder}. */ final class CivFrameSplitter { /** CI-V end-of-message terminator. */ private static final byte END_MARKER = (byte) 0xFD; + /** CI-V preamble byte; a command opens with two of these ({@code FE FE}). */ + private static final byte PREAMBLE = (byte) 0xFE; + + /** + * Hard cap on the carried-over remainder. A well-formed CI-V command this app + * exchanges is a few to a few dozen bytes, so the reassembly buffer should + * never approach this. It only matters when the terminator {@code 0xFD} never + * arrives — a rig on the wrong baud, line noise, or a misbehaving network peer + * streaming garbage — which would otherwise grow {@code dataBuffer} without + * bound and eventually OOM the app. 1 KiB is orders of magnitude above any + * real frame yet keeps memory firmly bounded. + */ + private static final int MAX_BUFFERED_BYTES = 1024; private CivFrameSplitter() { } @@ -65,6 +83,43 @@ static Result split(byte[] buffered, byte[] incoming) { start = i + 1; } } - return new Result(commands, Arrays.copyOfRange(combined, start, combined.length)); + return new Result(commands, boundRemainder(Arrays.copyOfRange(combined, start, combined.length))); + } + + /** + * Keep the trailing remainder from growing without bound when the terminator + * never arrives. A remainder holds a command still in progress (it contains no + * {@code 0xFD}, or it would have been split off already), so under normal + * operation it stays tiny. Only a malformed stream lets it exceed + * {@link #MAX_BUFFERED_BYTES}; when it does, resynchronise to the most recent + * {@code FE FE} preamble — the earliest point a valid command could still begin + * — discarding the unparseable bytes before it. Bytes preceding that preamble + * can never complete a frame (a valid one would have ended in {@code 0xFD} and + * been emitted), so dropping them is lossless for well-formed traffic and only + * ever trims garbage. If no preamble is present the buffer is pure noise: keep + * at most a trailing {@code 0xFE} in case a preamble is split across the read + * boundary. A final trailing-window clamp guarantees boundedness even in the + * pathological case of a lone preamble followed by a huge terminator-less run. + */ + private static byte[] boundRemainder(byte[] remainder) { + if (remainder.length <= MAX_BUFFERED_BYTES) { + return remainder; + } + for (int i = remainder.length - 2; i >= 0; i--) { + if (remainder[i] == PREAMBLE && remainder[i + 1] == PREAMBLE) { + // Resynchronise to this preamble. If the tail from here still + // exceeds the cap (a lone preamble ahead of a huge terminator-less + // run) keep only its trailing window. Compute the copy start first + // so we allocate at most MAX_BUFFERED_BYTES bytes rather than the + // full oversized remainder — this guard fires precisely in the + // malformed-stream case, where the extra copy would hurt most. + int from = Math.max(i, remainder.length - MAX_BUFFERED_BYTES); + return Arrays.copyOfRange(remainder, from, remainder.length); + } + } + if (remainder[remainder.length - 1] == PREAMBLE) { + return new byte[]{PREAMBLE}; + } + return new byte[0]; } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CivFrameSplitterTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CivFrameSplitterTest.java index 9b0b5d189..1983a58c0 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CivFrameSplitterTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CivFrameSplitterTest.java @@ -148,6 +148,110 @@ public void emptyInputs_yieldNoCommandsAndEmptyRemainder() { assertThat(r.remainder).hasLength(0); } + // The reassembly buffer must not grow without bound. The remainder holds a + // command still in progress (no terminator yet), so under normal operation it + // stays tiny; but a stream that never sends 0xFD — a rig on the wrong baud, + // line noise, or a misbehaving network peer — would otherwise accumulate every + // byte forever and eventually OOM the app. The cap below (1 KiB) is orders of + // magnitude above any real CI-V command, so these tests only exercise the + // malformed-stream guard and never affect well-formed traffic. + private static final int MAX_BUFFERED_BYTES = 1024; + + @Test + public void terminatorlessStream_remainderStaysBounded() { + // Simulate a rig streaming garbage that never contains a terminator across + // many callbacks. Without the cap the carried buffer would grow to megabytes. + byte[] buffered = new byte[0]; + byte[] noise = new byte[512]; // no 0xFD, no FE FE + for (int i = 0; i < noise.length; i++) { + noise[i] = (byte) (i & 0x7F); // 0x00..0x7F, never 0xFD or 0xFE + } + for (int call = 0; call < 100; call++) { + CivFrameSplitter.Result r = CivFrameSplitter.split(buffered, noise); + assertThat(r.commands).isEmpty(); + assertThat(r.remainder.length).isAtMost(MAX_BUFFERED_BYTES); + buffered = r.remainder; + } + } + + @Test + public void overflow_resyncsToMostRecentPreamble() { + // Garbage with no terminator, then a fresh command's preamble and start. + byte[] garbage = new byte[1100]; // all 0x00: no FD, no FE + byte[] partialNext = {(byte) 0xFE, (byte) 0xFE, (byte) 0xE0, (byte) 0xA4, (byte) 0x03}; + CivFrameSplitter.Result r = CivFrameSplitter.split(new byte[0], concat(garbage, partialNext)); + + assertThat(r.commands).isEmpty(); + // Everything before the last FE FE is unparseable and is dropped; the live + // command-in-progress is preserved so the next chunk completes it. + assertThat(r.remainder).isEqualTo(partialNext); + } + + @Test + public void overflow_resyncedFrameStillCompletesOnNextChunk() { + byte[] garbage = new byte[1100]; + byte[] frame = freqFrame(); + byte[] frameFirst = java.util.Arrays.copyOfRange(frame, 0, 5); + byte[] frameRest = java.util.Arrays.copyOfRange(frame, 5, frame.length); + + CivFrameSplitter.Result r1 = CivFrameSplitter.split(new byte[0], concat(garbage, frameFirst)); + assertThat(r1.commands).isEmpty(); + assertThat(r1.remainder).isEqualTo(frameFirst); + + CivFrameSplitter.Result r2 = CivFrameSplitter.split(r1.remainder, frameRest); + assertThat(r2.commands).hasSize(1); + assertThat(r2.commands.get(0)).isEqualTo(frame); + assertThat(r2.remainder).hasLength(0); + } + + @Test + public void overflow_noPreambleDropsNoiseButKeepsTrailingPreambleByte() { + byte[] noiseEndingInFe = new byte[1100]; + noiseEndingInFe[noiseEndingInFe.length - 1] = (byte) 0xFE; // possible split preamble + CivFrameSplitter.Result r = CivFrameSplitter.split(new byte[0], noiseEndingInFe); + + assertThat(r.commands).isEmpty(); + // Keep the lone trailing 0xFE so a preamble split across the read boundary + // still reassembles; everything else was pure noise. + assertThat(r.remainder).isEqualTo(new byte[]{(byte) 0xFE}); + } + + @Test + public void overflow_pureNoiseDropsEverything() { + byte[] noise = new byte[1100]; // all 0x00: no FD, no FE + CivFrameSplitter.Result r = CivFrameSplitter.split(new byte[0], noise); + + assertThat(r.commands).isEmpty(); + assertThat(r.remainder).hasLength(0); + } + + @Test + public void overflow_lonePreambleWithHugeTail_isHardCapped() { + // Pathological: a preamble at the very start followed by a terminator-less + // run longer than the cap. Resync alone can't shrink it, so the trailing + // window clamp guarantees boundedness. + byte[] buf = new byte[2200]; + buf[0] = (byte) 0xFE; + buf[1] = (byte) 0xFE; // FE FE at index 0, nothing else notable, no FD + CivFrameSplitter.Result r = CivFrameSplitter.split(new byte[0], buf); + + assertThat(r.commands).isEmpty(); + assertThat(r.remainder.length).isEqualTo(MAX_BUFFERED_BYTES); + } + + @Test + public void largeButUnderCapPartial_isCarriedUnchanged() { + // A big-but-legitimate partial command (still under the cap) must pass + // through untouched — the guard only trims genuinely oversized buffers. + byte[] partial = new byte[MAX_BUFFERED_BYTES]; + partial[0] = (byte) 0xFE; + partial[1] = (byte) 0xFE; // valid-looking start, no terminator yet + CivFrameSplitter.Result r = CivFrameSplitter.split(new byte[0], partial); + + assertThat(r.commands).isEmpty(); + assertThat(r.remainder).isEqualTo(partial); + } + private static byte[] concat(byte[] a, byte[] b) { byte[] out = new byte[a.length + b.length]; System.arraycopy(a, 0, out, 0, a.length); From 71bdef9d969e49f0db47cf1aacd7777786d359bb Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 16:15:57 -0500 Subject: [PATCH 058/113] Stop stale deep-pass decodes from rewinding the QSO sequence (#650) * Stop stale deep-pass decodes from rewinding the QSO sequence Three QSOs in a POTA run on 2026-07-23 (K0OTC, K0OBX, N8GK) logged "QSO: advance order 4->2": the fast pass correctly advanced us to RR73 on the partner's R-report, then ~20ms later a replayed decode of that partner's *opening grid* knocked the sequence back to order 2 and we re-sent a signal report they had already answered, costing a full 30s exchange each time. From the operator's seat: "I kept sending a signal report even though they sent my report several times." Three defects combined: 1. parseMessageToFunction applied `functionOrder = newOrder + 1` unconditionally, so an evidence-only pass could move the QSO backwards. Deep/late/stashed passes are documented as positive evidence (advance/RR73/complete/enqueue) but nothing enforced it. New isStaleEvidence() gates them: they may advance or hold, never rewind. The fast pass stays authoritative for its own cycle, and order 6 (CQ baseline, not the top of the ladder) is exempt. A gated decode still clears the no-reply count - it is stale, not absent. 2. MainViewModel drained the mid-TX decode stash below the `messages.size() == 0` early return. The delivery right after key-up is our own transmit slot, which is silent but for the loopback echo and so filters to zero kept messages every time (213 of 213 slots in the field log). The drain was therefore unreachable exactly when it was meant to fire, and the stash spilled into the next receive slot on top of that slot's newer evidence. Hoisted to the top of afterDecode; drain() empties the stash so the existing later call is now just a same-delivery fast path. 3. PendingSequencerDecodes.MAX_AGE_MS was 60s - two full cycles, long enough for a decode to outlive the exchange it describes. Cut to 30s, still leaving a stashed decode room to replay after key-up. Fix 1 stops the symptom; 2 and 3 stop the stale decode being in flight. Tests: isStaleEvidence truth table, end-to-end rewind regressions through parseMessageToFunction (grid-after-RR73, report-after-73, fast-pass still allowed to lower, no-reply still cleared, repeat of the current step still accepted), and the stash age cap. Full suite 2483 pass. Co-Authored-By: Claude Opus 4.8 (1M context) * Address review: align slot-vs-cycle wording in docs and test The MAX_AGE_MS Javadoc calls 30s "one full FT8 cycle (2 slots)", so update the class-level Javadoc (which still said "two full cycles") and rename the eviction test (decodeFromTwoCyclesAgo -> decodeOlderThanOneCycle, twoCyclesOld -> oldDecode) to match. No behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/MainViewModel.java | 49 +++++++++++++---- .../k1af/ft8af/PendingSequencerDecodes.java | 23 +++++--- .../ft8af/ft8transmit/FT8TransmitSignal.java | 40 ++++++++++++++ .../ft8af/PendingSequencerDecodesTest.java | 23 ++++++++ .../ft8transmit/EvidenceOnlyParseTest.java | 52 +++++++++++++++++++ .../ft8transmit/FT8TransmitSignalTest.java | 48 +++++++++++++++++ 6 files changed, 219 insertions(+), 16 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 4d79a470d..9fb8874a7 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -584,6 +584,16 @@ public void beforeListen(long utc) { @Override public void afterDecode(long utc, float time_sec, int sequential , ArrayList decoded, boolean isDeep) { + // Replay decodes stashed during the last transmission at the first + // delivery with TX idle. This must run before the early returns + // below: the delivery that follows key-up is our own TX slot, which + // is silent except for the loopback echo and so filters down to zero + // kept messages every single time. Draining below those returns meant + // the stash never emptied on our own slot at all — it survived into + // the next receive slot and landed on top of that slot's fresh, newer + // evidence, rewinding the QSO a step. + drainStashedSequencerDecodes(); + if (decoded.size() == 0) { // beforeListen set mutableIsDecoding=true at the start of this cycle; // clear it here so a silent slot doesn't leave the spectrum-display @@ -690,15 +700,12 @@ public void afterDecode(long utc, float time_sec, int sequential ft8TransmitSignal.parseMessageToFunction(messages, true); } } - // First delivery with TX idle (typically the own-slot fast pass - // ~1s after key-up): replay anything stashed mid-TX. - if (!ft8TransmitSignal.isTransmitting() && !pendingSequencerDecodes.isEmpty()) { - ArrayList stashed = - pendingSequencerDecodes.drain(UtcTimer.getSystemTime()); - if (!stashed.isEmpty()) { - ft8TransmitSignal.parseMessageToFunction(stashed, true); - } - } + // Fast path for a delivery that began mid-TX and stashed above, but + // whose transmission finished before we got here: replay it now + // rather than waiting for the next delivery. drain() empties the + // stash, so this can never re-apply what the call at the top of + // afterDecode already handled. + drainStashedSequencerDecodes(); decodeCycleState.labelsAfterPass(messages, isDeep); @@ -1253,6 +1260,30 @@ private WsjtxCodec.Status buildWsjtxStatus() { ""); // tx message } + /** + * Replay decodes stashed during a transmission into the QSO sequencer, if any + * are pending and TX is idle. + * + *

Called at the top of every decode delivery and again after the deep-pass + * handling. The first call is the one that matters: the delivery right after + * key-up is our own transmit slot, whose only decode is the loopback echo of + * our own signal, so it filters down to zero kept messages and returns early. + * Draining only after that return left the stash to be replayed a full cycle + * later, on top of newer evidence — rewinding the QSO + * (see {@link PendingSequencerDecodes}). + * + *

{@link PendingSequencerDecodes#drain} empties the stash and drops entries + * past its age cap, so repeat calls are cheap and cannot double-apply. + */ + private void drainStashedSequencerDecodes() { + if (ft8TransmitSignal == null || ft8TransmitSignal.isTransmitting()) return; + if (pendingSequencerDecodes.isEmpty()) return; + ArrayList stashed = pendingSequencerDecodes.drain(UtcTimer.getSystemTime()); + if (!stashed.isEmpty()) { + ft8TransmitSignal.parseMessageToFunction(stashed, true); + } + } + /** Broadcast a slot's decodes as WSJT-X Decode messages. */ private void broadcastWsjtxDecodes(ArrayList messages) { WsjtxUdpService svc = WsjtxUdpService.INSTANCE; diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/PendingSequencerDecodes.java b/ft8af/app/src/main/java/com/k1af/ft8af/PendingSequencerDecodes.java index bdcccd5c2..fa60160f8 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/PendingSequencerDecodes.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/PendingSequencerDecodes.java @@ -19,9 +19,9 @@ * {@code FT8TransmitSignal.parseMessageToFunction(list, true)}. * *

Entries older than {@link #MAX_AGE_MS} are evicted on every stash/drain: - * a reply is only evidence for the QSO it belongs to, and after two full - * cycles the fast pass has since made its own calls — acting on an older - * stashed report could advance a state it no longer describes. Matching is + * a reply is only evidence for the QSO it belongs to, and after one full + * cycle (two slots) the fast pass has since made its own calls — acting on an + * older stashed report could advance a state it no longer describes. Matching is * additionally guarded by the sequencer itself (from/to callsign and slot * parity checks), so the age cap is a backstop, not the primary filter. * @@ -32,11 +32,20 @@ public final class PendingSequencerDecodes { /** * Maximum age of a stashed decode, measured from the message's slot time - * ({@code Ft8Message.utcTime}). Two full FT8 cycles (4 slots) — long enough - * to survive TX plus one silent delivery gap, short enough that a stale - * report can't leak into a later QSO. + * ({@code Ft8Message.utcTime}). One full FT8 cycle (2 slots) — long enough to + * survive the transmission it was stashed behind and be replayed at the next + * key-down gap, short enough that it cannot describe a QSO state the fast + * pass has already moved past. + * + *

This was two cycles (60s). At that age a decode outlives a whole + * exchange: the partner's opening grid, stashed behind our report, was still + * "fresh" a cycle later when the fast pass had already advanced us to RR73 on + * their R-report. Replaying it rewound the QSO and re-sent the report. The + * sequencer refuses to rewind now + * ({@link com.k1af.ft8af.ft8transmit.FT8TransmitSignal#isStaleEvidence}); this + * cap keeps such decodes from reaching it at all. */ - static final long MAX_AGE_MS = 60_000; + static final long MAX_AGE_MS = 30_000; private final ArrayList pending = new ArrayList<>(); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java index fb5ee34e0..208abd094 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java @@ -1679,6 +1679,17 @@ public void parseMessageToFunction(ArrayList msgList, boolean eviden if (newOrder != -1) {//message received but QSO not yet complete + // A deep/late/stashed pass may re-deliver a decode we already acted on + // (e.g. the partner's opening grid replayed a cycle after we advanced to + // RR73). Applying it would walk the QSO backwards and re-send a message + // the partner has already answered. Evidence-only passes advance, never rewind. + if (isStaleEvidence(evidenceOnly, functionOrder, newOrder)) { + GeneralVariables.fileLog("QSO: ignore stale evidence order " + functionOrder + + " newOrder=" + newOrder + + " with " + (toCallsign != null ? toCallsign.callsign : "?")); + return; + } + // originally newOrder == 1, but sometimes the other party sends a signal report directly, i.e. message 2 if (newOrder == 1 || newOrder == 2) {// this is the first reply from the other party resetTargetReport();// reset the signal reports @@ -2339,6 +2350,35 @@ static boolean shouldCompleteQso(boolean evidenceOnly, int functionOrder, int ne && (noReplyLimit == 0));// no-reply "ignore" at RR73: QSO already logged, don't hold the run frequency for minutes } + /** + * Whether a deep/late-pass decode describes a QSO state we have already moved + * past, and must therefore be ignored. + * + *

Deep, subtraction and stashed passes re-deliver decodes out of order: a + * pass finishing during our transmission is replayed after key-up, by which + * time the fast pass may already have advanced the QSO on newer evidence. The + * partner's opening grid (order 1) replayed after we reached RR73 (order 4) + * would otherwise set us back to order 2 and re-send a signal report the + * partner already answered — the sequencer walking backwards mid-QSO. + * + *

Only evidence-only passes are gated. The fast pass observes the current + * cycle and its verdict is authoritative even when it lowers the order (the + * partner really did fall back a step). Order 6 is the CQ baseline rather + * than the top of the ladder, so a QSO starting from CQ is never "stale". + * + *

Package-visible for testing. + * + * @param evidenceOnly this is a deep/late/stashed parse (positive evidence only) + * @param functionOrder my current message order (1-6) + * @param newOrder order of the partner's message this pass (never -1 here) + * @return true when the pass would move the QSO backwards and should be dropped + */ + static boolean isStaleEvidence(boolean evidenceOnly, int functionOrder, int newOrder) { + if (!evidenceOnly) return false;// fast pass is authoritative for its own cycle + if (functionOrder == 6) return false;// CQ baseline: any reply legitimately starts a QSO + return newOrder + 1 < functionOrder;// would rewind the sequence + } + /** * Whether a force-log should actually save the QSO record and trigger the * celebration. A QSO that never progressed past order 2 (no report diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/PendingSequencerDecodesTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/PendingSequencerDecodesTest.java index 25e60df5e..3d73232de 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/PendingSequencerDecodesTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/PendingSequencerDecodesTest.java @@ -94,4 +94,27 @@ public void stashEvictsStaleEntriesBeforeAdding() { assertThat(pending.drain(now)).containsExactly(fresh); } + + @Test + public void maxAgeIsAtMostOneFt8Cycle() { + // The cap must not outlive the exchange the decode belongs to. At the + // old two-cycle (60s) setting, a partner's opening grid stashed behind + // our report was still "fresh" a cycle later, when the fast pass had + // already advanced us to RR73 on their R-report -- replaying it rewound + // the QSO (POTA field report 2026-07-23). One 15s slot must survive so + // a decode stashed mid-TX still replays after key-up. + assertThat(PendingSequencerDecodes.MAX_AGE_MS).isAtLeast(15_000L); + assertThat(PendingSequencerDecodes.MAX_AGE_MS).isAtMost(30_000L); + } + + @Test + public void decodeOlderThanOneCycleIsEvicted() { + PendingSequencerDecodes pending = new PendingSequencerDecodes(); + Ft8Message oldDecode = msgAt(0); + pending.stash(list(oldDecode), 0); + + // 30_001 ms == one full FT8 cycle (two 15s slots) past MAX_AGE_MS, so the + // decode is evicted: the exchange it belonged to has moved on. + assertThat(pending.drain(30_001)).isEmpty(); + } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/EvidenceOnlyParseTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/EvidenceOnlyParseTest.java index 2036d41cd..453f34ac8 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/EvidenceOnlyParseTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/EvidenceOnlyParseTest.java @@ -201,4 +201,56 @@ public void houndMode_ignoresDeepPasses() { signal.parseMessageToFunction(list(rxMsg("K1AF", "N2JFD", "R-18")), true); assertThat(signal.getFunctionOrder()).isEqualTo(2); } + + // ---- stale evidence must not rewind the QSO ----------------------------- + // Field report (POTA 2026-07-23): three QSOs logged "advance order 4->2". + // The fast pass correctly advanced us to RR73 on the partner's R-report, + // then ~20ms later a replayed decode of that partner's *opening grid* -- + // stashed behind our previous transmission and drained a cycle late -- + // knocked the sequence back to order 2. We re-sent the signal report the + // partner had already answered, costing a full 30s exchange each time. + + @Test + public void deepStaleGridAfterRR73_doesNotRewindToReport() { + startQsoAtOrder(4);// we are sending RR73 + signal.parseMessageToFunction(list(rxMsg("K1AF", "N2JFD", "FN20")), true); + assertThat(signal.getFunctionOrder()).isEqualTo(4);// still RR73, not 2 + } + + @Test + public void deepStaleReportAfter73_doesNotRewind() { + startQsoAtOrder(5);// we are sending 73 + signal.parseMessageToFunction(list(rxMsg("K1AF", "N2JFD", "R-18")), true); + assertThat(signal.getFunctionOrder()).isEqualTo(5); + } + + @Test + public void fastPassMayStillLowerTheOrder() { + // Only deep/replayed evidence is gated. The fast pass sees the current + // cycle, so if the partner genuinely fell back to calling us with a + // grid, it still re-seeds the report step. + startQsoAtOrder(4); + signal.parseMessageToFunction(list(rxMsg("K1AF", "N2JFD", "FN20")), false); + assertThat(signal.getFunctionOrder()).isEqualTo(2); + } + + @Test + public void deepStaleEvidence_stillClearsNoReplyCount() { + // The decode is too old to move the sequence, but it is still proof the + // partner was transmitting: don't let it count towards giving up. + startQsoAtOrder(4); + GeneralVariables.noReplyCount = 2; + signal.parseMessageToFunction(list(rxMsg("K1AF", "N2JFD", "FN20")), true); + assertThat(signal.getFunctionOrder()).isEqualTo(4); + assertThat(GeneralVariables.noReplyCount).isEqualTo(0); + } + + @Test + public void deepEvidenceRepeatingCurrentStep_stillAccepted() { + // newOrder+1 == functionOrder is not a rewind: at RR73 the partner + // re-sending R-report keeps us at RR73 and is normal QSO behaviour. + startQsoAtOrder(4); + signal.parseMessageToFunction(list(rxMsg("K1AF", "N2JFD", "R-18")), true); + assertThat(signal.getFunctionOrder()).isEqualTo(4); + } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignalTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignalTest.java index c56515a1e..6d439be94 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignalTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignalTest.java @@ -699,4 +699,52 @@ public void rebuildTimer_zeroOffsetStaysZero() { old.delete(); } } + + // ---- isStaleEvidence ----------------------------------------------------- + // Deep/late/stashed passes re-deliver decodes out of order: a pass finishing + // during our transmission is replayed after key-up, by which time the fast + // pass may already have advanced the QSO on newer evidence. Such a pass may + // move the sequence forward but never back — a partner's opening grid + // replayed after we reached RR73 must not rewind us to sending a report. + // Field case (POTA 2026-07-23, K0OBX/N8GK/K0OTC): "advance order 4->2". + + @Test + public void stale_fastPassIsNeverStale() { + // The fast pass observes the current cycle; its verdict stands even when + // it lowers the order (the partner really did fall back a step). + assertThat(FT8TransmitSignal.isStaleEvidence( + /*evidenceOnly*/ false, /*order*/ 4, /*newOrder*/ 1)).isFalse(); + assertThat(FT8TransmitSignal.isStaleEvidence(false, 5, 1)).isFalse(); + } + + @Test + public void stale_deepGridAfterRR73_isStale() { + // The exact field failure: at RR73 (4), a replayed opening grid (1) + // would set order back to 2 and re-send the signal report. + assertThat(FT8TransmitSignal.isStaleEvidence(true, 4, 1)).isTrue(); + } + + @Test + public void stale_deepEvidenceThatAdvances_isNotStale() { + // At order 2 the partner's R-report (3) advances to RR73 (4). + assertThat(FT8TransmitSignal.isStaleEvidence(true, 2, 3)).isFalse(); + // At RR73 (4) their 73 (5) completes. + assertThat(FT8TransmitSignal.isStaleEvidence(true, 4, 5)).isFalse(); + } + + @Test + public void stale_deepEvidenceHoldingSameOrder_isNotStale() { + // Partner repeats the message we already acted on: newOrder+1 == order. + // Not a rewind, and re-affirming resets the no-reply count, so allow it. + assertThat(FT8TransmitSignal.isStaleEvidence(true, 4, 3)).isFalse(); + assertThat(FT8TransmitSignal.isStaleEvidence(true, 2, 1)).isFalse(); + } + + @Test + public void stale_cqBaselineIsNeverStale() { + // Order 6 is the CQ baseline, not the top of the ladder: a reply + // arriving there legitimately starts a QSO at a lower order. + assertThat(FT8TransmitSignal.isStaleEvidence(true, 6, 1)).isFalse(); + assertThat(FT8TransmitSignal.isStaleEvidence(true, 6, 2)).isFalse(); + } } From 389cf54f2c854dfe22045218319581c58abae805 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 16:16:11 -0500 Subject: [PATCH 059/113] Fix USB CAT write/read NPE when port I/O races an in-progress (re)open (#651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix USB CAT write/read NPE when a port I/O races an in-progress (re)open CommonUsbSerialPort.open() publishes mConnection *before* openInt() assigns mReadEndpoint/mWriteEndpoint (openInt and the catch-path close() both need the connection during setup). This leaves a window where mConnection != null but the endpoints are still null. read()/write() only guarded `mConnection == null`, so a call landing in that window passed the check and then NPE'd dereferencing the endpoint — write() at `mWriteEndpoint.getMaxPacketSize()`. sendData() catches IOException but not the RuntimeException NPE, so it escaped to the TX worker thread and crashed the app. The window is hit in practice during CAT auto-reconnect: prepare() allocates a fresh port (endpoints null) and a TX PTT firing from the transmit thread while open() is running keys the crash (observed on a Pixel 8 / Android 16: Yaesu39 setPTT -> FT8TransmitSignal -> CommonUsbSerialPort.write). Guard the endpoint as well as the connection via a small, unit-testable ioEndpointReady() helper, applied symmetrically to read() and write(). A missing connection OR endpoint now throws the same recoverable IOException("Connection closed") the CAT/TX layer already handles instead of crashing. Strict superset of the previous check — no behavior change once the port is fully open. Co-Authored-By: Claude Opus 4.8 (1M context) * Address review: snapshot connection/endpoint in read()/write() A concurrent close() (cable yanked on another thread) nulls the mConnection / mReadEndpoint / mWriteEndpoint fields, so an in-flight read()/write() that dereferenced the live fields could still NPE after the readiness guard passed. Read them into locals once, up front, and use the locals throughout — a concurrent close now degrades to the existing recoverable IOException path instead of crashing the IO thread. The guard still covers the (re)open race the PR targets. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../ft8af/serialport/CommonUsbSerialPort.java | 53 ++++++++++++++++--- .../CommonUsbSerialPortEndpointReadyTest.java | 43 +++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/serialport/CommonUsbSerialPortEndpointReadyTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/serialport/CommonUsbSerialPort.java b/ft8af/app/src/main/java/com/k1af/ft8af/serialport/CommonUsbSerialPort.java index 5c26db668..88d0f6fa7 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/serialport/CommonUsbSerialPort.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/serialport/CommonUsbSerialPort.java @@ -134,6 +134,29 @@ public void open(UsbDeviceConnection connection) throws IOException { protected abstract void openInt(UsbDeviceConnection connection) throws IOException; + /** + * Whether the port is ready for I/O on the given endpoint. + * + *

A port is usable only once {@link #open} has BOTH published the + * connection and {@code openInt()} has assigned the endpoint. {@code open()} + * deliberately sets {@link #mConnection} (line "mConnection = connection") + * before calling {@code openInt()} — {@code openInt}/{@code close} + * need the connection during setup — so there is a window where + * {@code mConnection != null} but the endpoint is still {@code null}. A + * {@code read()}/{@code write()} from another thread during that window + * (e.g. a TX PTT firing while CAT auto-reconnect is re-opening a freshly + * allocated port) would pass a bare {@code mConnection == null} check and + * then NPE dereferencing the endpoint. Treating a missing connection OR + * endpoint as "not open" turns that crash into a recoverable + * {@link IOException} the CAT/TX layer already handles. + * + *

Extracted as a package-private static so the readiness decision is + * unit-testable without a live {@link UsbDeviceConnection}/{@link UsbEndpoint}. + */ + static boolean ioEndpointReady(Object connection, Object endpoint) { + return connection != null && endpoint != null; + } + @Override public void close() throws IOException { if (mConnection == null) { @@ -170,7 +193,15 @@ public int read(final byte[] dest, final int timeout) throws IOException { } protected int read(final byte[] dest, final int timeout, boolean testConnection) throws IOException { - if(mConnection == null) { + // Snapshot the connection and endpoint into locals so a concurrent close() + // (cable yanked on another thread) nulling the fields mid-read can't NPE + // here — it stays a recoverable IOException. mReadEndpoint (like + // mWriteEndpoint) is also null until openInt() runs, which open() calls + // only after publishing mConnection, so the guard covers the (re)open race + // too. See ioEndpointReady. + final UsbDeviceConnection connection = mConnection; + final UsbEndpoint readEndpoint = mReadEndpoint; + if (!ioEndpointReady(connection, readEndpoint)) { throw new IOException("Connection closed"); } if(dest.length <= 0) { @@ -188,7 +219,7 @@ protected int read(final byte[] dest, final int timeout, boolean testConnection) // data loss / crashes were observed with timeout up to 200 msec long endTime = testConnection ? MonotonicClock.millis() + timeout : 0; int readMax = Math.min(dest.length, MAX_READ_SIZE); - nread = mConnection.bulkTransfer(mReadEndpoint, dest, readMax, timeout); + nread = connection.bulkTransfer(readEndpoint, dest, readMax, timeout); // Android error propagation is improvable: // nread == -1 can be: timeout, connection lost, buffer to small, ??? if(nread == -1 && testConnection && MonotonicClock.millis() < endTime) @@ -199,7 +230,7 @@ protected int read(final byte[] dest, final int timeout, boolean testConnection) if (!mUsbRequest.queue(buf, dest.length)) { throw new IOException("Queueing USB request failed"); } - final UsbRequest response = mConnection.requestWait(); + final UsbRequest response = connection.requestWait(); if (response == null) { throw new IOException("Waiting for USB request failed"); } @@ -218,7 +249,17 @@ public void write(final byte[] src, final int timeout) throws IOException { int offset = 0; final long endTime = (timeout == 0) ? 0 : (MonotonicClock.millis() + timeout); - if(mConnection == null) { + // Snapshot the connection and endpoint into locals: close() (called from + // another thread when the cable is yanked) nulls the mConnection / + // mWriteEndpoint fields, so reading them once here — rather than + // dereferencing the live fields through the loop below — means a + // concurrent close can't turn an in-flight write into an NPE. It stays a + // recoverable IOException instead. The guard also covers the (re)open + // window where open() publishes mConnection before openInt() assigns + // mWriteEndpoint. See ioEndpointReady. + final UsbDeviceConnection connection = mConnection; + final UsbEndpoint writeEndpoint = mWriteEndpoint; + if (!ioEndpointReady(connection, writeEndpoint)) { throw new IOException("Connection closed"); } while (offset < src.length) { @@ -230,7 +271,7 @@ public void write(final byte[] src, final int timeout) throws IOException { final byte[] writeBuffer; if (mWriteBuffer == null) { - mWriteBuffer = new byte[mWriteEndpoint.getMaxPacketSize()]; + mWriteBuffer = new byte[writeEndpoint.getMaxPacketSize()]; } requestLength = Math.min(src.length - offset, mWriteBuffer.length); if (offset == 0) { @@ -250,7 +291,7 @@ public void write(final byte[] src, final int timeout) throws IOException { if (requestTimeout < 0) { actualLength = -2; } else { - actualLength = mConnection.bulkTransfer(mWriteEndpoint, writeBuffer, requestLength, requestTimeout); + actualLength = connection.bulkTransfer(writeEndpoint, writeBuffer, requestLength, requestTimeout); } } if (DEBUG) { diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/serialport/CommonUsbSerialPortEndpointReadyTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/serialport/CommonUsbSerialPortEndpointReadyTest.java new file mode 100644 index 000000000..409ef22df --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/serialport/CommonUsbSerialPortEndpointReadyTest.java @@ -0,0 +1,43 @@ +package com.k1af.ft8af.serialport; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Pure-JVM coverage for {@link CommonUsbSerialPort#ioEndpointReady}, the + * readiness decision behind {@code read()}/{@code write()} throwing a + * recoverable {@link java.io.IOException} instead of crashing. + * + *

Root cause reproduced by {@link #connectionPublishedButEndpointNotYetAssigned_notReady}: + * {@code open()} sets {@code mConnection} before {@code openInt()} + * assigns the read/write endpoint, so a concurrent I/O call (a TX PTT firing + * during CAT auto-reconnect on a freshly allocated port) can observe a non-null + * connection with a still-null endpoint. That case previously passed the bare + * {@code mConnection == null} check and then NPEd dereferencing the endpoint + * ({@code mWriteEndpoint.getMaxPacketSize()}); it must now read as "not open". + */ +public class CommonUsbSerialPortEndpointReadyTest { + + @Test + public void bothPresent_ready() { + assertThat(CommonUsbSerialPort.ioEndpointReady(new Object(), new Object())).isTrue(); + } + + @Test + public void connectionPublishedButEndpointNotYetAssigned_notReady() { + // The crash window: open() published the connection, openInt() has not + // yet assigned the endpoint. + assertThat(CommonUsbSerialPort.ioEndpointReady(new Object(), null)).isFalse(); + } + + @Test + public void nullConnection_notReady() { + assertThat(CommonUsbSerialPort.ioEndpointReady(null, new Object())).isFalse(); + } + + @Test + public void bothNull_notReady() { + assertThat(CommonUsbSerialPort.ioEndpointReady(null, null)).isFalse(); + } +} From 39bf86ad2191594dc06e0b2a30bfe61975640ddc Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 16:16:54 -0500 Subject: [PATCH 060/113] Fix web-logger hash-table page racing the shared callsign hashList (CME / HashMap corruption) (#656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix web-logger hash-table page racing the shared callsign hashList (CME / HashMap corruption) Ft8Message.hashList is a process-wide MessageHashMap (extends HashMap) whose writers — the decode thread, the DB-load thread, and the TX/Fox thread — all mutate it through the synchronized addHash(), several times per 15 s cycle. LogHttpServer.showCallsignHash(), however, iterated Ft8Message.hashList .entrySet() directly on a NanoHTTPD worker thread without holding the map's monitor, and that page auto-refreshes every 5 s while it is open. Iterating the live map off-thread races the synchronized writers: a concurrent put() throws ConcurrentModificationException mid-render, and — more seriously — a put()-driven resize concurrent with the iteration can corrupt the shared hash table the decoder relies on for callsign-hash resolution (HashMap is not safe for concurrent structural modification). The writers taking the monitor gave false safety because the reader never acquired it. Root cause: a reader that bypasses the writers' lock. Fix: add a synchronized snapshotEntries() to MessageHashMap that copies the entries under the monitor, and iterate that private snapshot in showCallsignHash(). This is the only off-thread structural iteration of hashList — the other reader, FT8TransmitSignal.comboFromOurFox(), already goes through the synchronized getCallsign(). Tests (MessageHashMapTest, pure JVM): - snapshotEntries_returnsAllCurrentEntries / _isDecoupledFromLaterMutation: the snapshot is a complete, independent point-in-time copy. - liveEntrySetIteration_throwsOnConcurrentStructuralModification: documents the fail-fast hazard the snapshot removes (deterministic CME). - snapshotEntries_survivesConcurrentWrites: a writer thread hammers addHash() while the test iterates snapshots; no CME/corruption (would throw against the old live-entrySet path). Stable across repeated runs. Full :app:testDebugUnitTest suite passes. Co-Authored-By: Claude Opus 4.8 (1M context) * Address review: exercise only addHash() in the snapshot-decoupling test Replace the map.clear() call (an unsynchronized HashMap method that doesn't match the production writer path) with addHash() using fresh unique keys, so the test structurally mutates the live map exactly the way the real decode/DB/TX writers do while still asserting the point-in-time snapshot is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/MessageHashMap.java | 30 +++++ .../com/k1af/ft8af/html/LogHttpServer.java | 7 +- .../com/k1af/ft8af/MessageHashMapTest.java | 111 ++++++++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MessageHashMap.java b/ft8af/app/src/main/java/com/k1af/ft8af/MessageHashMap.java index 6542effc0..5c81b1857 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MessageHashMap.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MessageHashMap.java @@ -7,7 +7,11 @@ import android.util.Log; +import java.util.AbstractMap; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; +import java.util.Map; public class MessageHashMap extends HashMap { private static final String TAG = "MessageHashMap"; @@ -56,6 +60,32 @@ public boolean checkHash(long hashCode) { // return false; } + /** + * Return an independent, point-in-time copy of the (hash → callsign) + * entries, taken while holding this map's monitor. + * + *

{@link #addHash} and {@link #getCallsign} are {@code synchronized} on this + * instance, so every writer mutates the backing {@code HashMap} under the + * monitor: the decode thread, the DB-load thread, and the TX/Fox thread all + * add hashes several times per 15 s cycle. A reader that iterates the + * live map from another thread races those writers. The web-logger's + * hash-table page ({@code LogHttpServer.showCallsignHash}) is built on a + * NanoHTTPD worker thread and used to iterate {@code entrySet()} directly, so a + * concurrent {@code put} could throw {@link java.util.ConcurrentModificationException} + * mid-render, and a {@code put}-driven resize racing the iteration can corrupt + * the very map the decoder relies on for hash resolution. Copying under the + * lock lets callers iterate a private snapshot without holding the monitor. + * + * @return a new list of the current entries, safe to iterate without locking + */ + public synchronized List> snapshotEntries() { + List> copy = new ArrayList<>(size()); + for (Map.Entry entry : entrySet()) { + copy.add(new AbstractMap.SimpleImmutableEntry<>(entry.getKey(), entry.getValue())); + } + return copy; + } + //Look up callsign by hash code public synchronized String getCallsign(long[] hashCode) { for (long l : hashCode) { diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java b/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java index 3d1fabdd7..b1bf5e6f4 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java @@ -968,7 +968,12 @@ private String showCallsignHash() { int order = 0; - for (Map.Entry entry : Ft8Message.hashList.entrySet()) { + // Iterate a snapshot taken under hashList's monitor: this page is served + // on a NanoHTTPD worker thread and auto-refreshes every 5 s, while the + // decode/DB/TX threads add hashes throughout a cycle. Iterating the live + // map here raced those synchronized writers (ConcurrentModificationException, + // or a resize corrupting the shared hash table). + for (Map.Entry entry : Ft8Message.hashList.snapshotEntries()) { HtmlContext.tableRowBegin(result, false, (order / 3) % 2 != 0); HtmlContext.tableCell(result, entry.getValue()); diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/MessageHashMapTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/MessageHashMapTest.java index 7797477bc..414a3fefd 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/MessageHashMapTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/MessageHashMapTest.java @@ -1,9 +1,16 @@ package com.k1af.ft8af; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; import org.junit.Test; +import java.util.ConcurrentModificationException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + /** * Exercise {@link MessageHashMap}'s callsign-hash bookkeeping. The class * extends HashMap and adds three guards on top of {@code put}: it filters the @@ -96,4 +103,108 @@ public void addHash_nullCallsign_isIgnoredNotCrash() { assertThat(map.checkHash(0x1234L)).isFalse(); assertThat(map).isEmpty(); } + + @Test + public void snapshotEntries_returnsAllCurrentEntries() { + MessageHashMap map = new MessageHashMap(); + map.addHash(0x10L, "K1ABC"); + map.addHash(0x20L, "VE3XYZ"); + map.addHash(0x30L, "JA1AA"); + + List> snap = map.snapshotEntries(); + assertThat(snap).hasSize(3); + // Every stored (hash, callsign) pair is present in the snapshot. + for (Map.Entry e : snap) { + assertThat(map.get(e.getKey())).isEqualTo(e.getValue()); + } + } + + @Test + public void snapshotEntries_isDecoupledFromLaterMutation() { + // The snapshot is a point-in-time copy: mutating the live map afterwards + // must neither change the snapshot's contents nor throw while it is + // iterated. This is the property the web-logger's hash-table page relies + // on to render safely off the decode thread. + MessageHashMap map = new MessageHashMap(); + map.addHash(1L, "K1ABC"); + map.addHash(2L, "W1AW"); + + List> snap = map.snapshotEntries(); + assertThat(snap).hasSize(2); + + // Structurally modify the live map via the production writer path + // (addHash with fresh keys) while iterating the snapshot; a live + // entrySet() would throw ConcurrentModificationException here. + long freshKey = 100L; + for (Map.Entry ignored : snap) { + map.addHash(freshKey, "N0CALL" + freshKey); + freshKey++; + } + // The live map grew, but the point-in-time snapshot is unchanged. + assertThat(snap).hasSize(2); + } + + @Test + public void liveEntrySetIteration_throwsOnConcurrentStructuralModification() { + // Documents the hazard snapshotEntries() exists to remove: iterating the + // live map while another writer put()s (as addHash does on the decode/DB/ + // TX threads) is a fail-fast ConcurrentModificationException. Reproduced + // deterministically single-threaded via a structural modification during + // iteration. + MessageHashMap map = new MessageHashMap(); + for (long h = 1; h <= 50; h++) { + map.addHash(h, "K" + h); + } + try { + for (Map.Entry e : map.entrySet()) { + map.put(1000L + e.getKey(), "X"); + } + fail("expected ConcurrentModificationException from live entrySet iteration"); + } catch (ConcurrentModificationException expected) { + // exactly the failure mode the web logger hit off-thread + } + } + + @Test + public void snapshotEntries_survivesConcurrentWrites() throws Exception { + // Stress the real cross-thread scenario: a writer thread hammers addHash + // (the sole production writer path — decode/DB/TX side) while this thread + // repeatedly snapshots and iterates (web-logger side). addHash and + // snapshotEntries both hold the map's monitor, so the reader must never + // observe a ConcurrentModificationException or a corrupted iteration. + // Iterating the live entrySet() here instead would throw within a handful + // of rounds as the writer's put()s resize the table. + MessageHashMap map = new MessageHashMap(); + AtomicBoolean writerDone = new AtomicBoolean(false); + AtomicReference writerError = new AtomicReference<>(); + + Thread writer = new Thread(() -> { + try { + // Fresh keys keep the table growing/resizing (the structural + // change that trips a live iterator). Bounded so the map size and + // runtime stay modest. + for (long h = 1; h <= 20000; h++) { + map.addHash(h, "K" + h); + } + } catch (Throwable t) { + writerError.set(t); + } finally { + writerDone.set(true); + } + }); + writer.start(); + + try { + while (!writerDone.get()) { + for (Map.Entry entry : map.snapshotEntries()) { + // touch both accessors exactly like showCallsignHash() does + entry.getKey(); + entry.getValue(); + } + } + } finally { + writer.join(); + } + assertThat(writerError.get()).isNull(); + } } From 8004159cf17669057588e5d19bc9e5e3c31e0221 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 16:17:16 -0500 Subject: [PATCH 061/113] =?UTF-8?q?Fix=20manual=20time-correction=20silent?= =?UTF-8?q?ly=20truncated=20to=20=C2=B12=20s=20on=20every=20relaunch=20(#6?= =?UTF-8?q?57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix manual time-correction silently truncated to ±2 s on every relaunch The manual clock correction (Settings → Time Sync) is clamped to ±5000 ms in the live UI, but the config-hydration reload path in DatabaseOpr clamped it to a stale ±2000 ms. Any correction the operator set beyond ±2 s was therefore silently truncated back to 2 s on the next app launch, leaving an offline (no-NTP) phone's clock offset wrong by up to 3 s — and incorrect device time is the single most common cause of FT8 decode failures. Root cause: a range mismatch between the write/live path and the reload path. TimeCorrection.kt widened the range to ±5 s (a field-reported Samsung A50 needed over 3 s while offline) and TimeSyncSettings.apply persists via clampCorrectionMs(±5000), but the DatabaseOpr timeCorrectionMs branch was never updated and still clamped to ±2000 (the GeneralVariables field comment also still said "Range -2000..2000"). Fix: centralize the bound as GeneralVariables.MANUAL_TIME_CORRECTION_MIN/MAX_MS (±5000, matching the authoritative TimeCorrection.kt range) behind a pure clampManualTimeCorrectionMs() helper, and have the DatabaseOpr reload path reuse it so the two paths can no longer drift. Byte-identical for every in-range value; the DB value itself was never rewritten, only the in-memory value applied to UtcTimer.delay, so no persisted data was corrupted. Testing: GeneralVariablesTimeCorrectionClampTest (Robolectric, 5 cases) pins that ±3 s / ±4.5 s corrections survive the clamp, boundary values are kept, out-of-range values clamp to ±5 s, and the bounds equal the live UI's ±5 s. The two "beyond 2 s" cases fail against the old ±2000 bound (verified). Co-Authored-By: Claude Opus 4.8 (1M context) * Address review: make GeneralVariables the single source of the correction bounds TimeCorrection.kt's TIME_CORRECTION_{MIN,MAX}_MS now reference GeneralVariables.MANUAL_TIME_CORRECTION_{MIN,MAX}_MS instead of duplicating the literals, so the UI slider range and the reload-time clamp (which DatabaseOpr relies on) can't drift apart. Java static-final int constant expressions inline at compile time, so this doesn't drag GeneralVariables' class init into the pure-logic file. Javadoc updated to reflect the new authority direction. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/GeneralVariables.java | 33 +++++++- .../com/k1af/ft8af/database/DatabaseOpr.java | 5 +- .../ft8af/ui/settings/TimeCorrection.kt | 16 +++- ...neralVariablesTimeCorrectionClampTest.java | 75 +++++++++++++++++++ 4 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/GeneralVariablesTimeCorrectionClampTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 20da4d9e8..a2f82668d 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -449,7 +449,7 @@ public static Context getMainContext() { public static int transmitDelay = 500;//Transmit delay; also allows decoding time for the previous cycle public static int pttDelay = 100;//PTT response time; radios typically need some response time after PTT command, default 100ms public static int lateStartTolerance = 2000;//Max ms of leading audio a late manual TX may clip past the per-mode audio slack (ModeProfile.audioSlackMillis) and still go out this cycle. Effective start budget is slack+tolerance. 0-4000. See issue #467. - public static int manualTimeCorrectionMs = 0;//Manual clock correction (ms) applied to UtcTimer.delay; for field use without internet NTP. Range -2000..2000. See TimeSyncSettings. + public static int manualTimeCorrectionMs = 0;//Manual clock correction (ms) applied to UtcTimer.delay; for field use without internet NTP. Range [MANUAL_TIME_CORRECTION_MIN_MS, MANUAL_TIME_CORRECTION_MAX_MS]. See TimeSyncSettings. public static boolean earlyDecode = true;//Fast turnaround: decode a shorter RX window so CQ decodes appear ~1s before the cycle boundary, enabling a next-slot reply. public static int operatingMode = FT8Common.FT8_MODE;//Current operating mode (FT8Common.FT8_MODE / FT4_MODE); persisted as config "operatingMode". public static int iaruRegion = 2;//Operator's IARU region (1/2/3), used to gate Message-Creator QSY frequency options to legal band edges. Default 2 (the Americas). See com.k1af.ft8af.message.SpecialMessage. @@ -710,6 +710,37 @@ public static void setSpectrumWidth(int width) { GeneralVariables.spectrumWidth = clamped; } + /** + * Inclusive bounds (ms) for the manual clock correction ({@link #manualTimeCorrectionMs} + * / {@code UtcTimer.delay}). This is the single source of truth for the range: + * the live settings UI's {@code TIME_CORRECTION_MIN_MS}/{@code TIME_CORRECTION_MAX_MS} + * in {@code TimeCorrection.kt} now reference these constants, so the UI slider and + * the reload-time clamp ({@link #clampManualTimeCorrectionMs}) can't drift apart. + * The range is ±5 s (widened from ±2 s so an offline phone that has drifted several + * seconds — a field-reported Samsung A50 needed over 3 s — can be pulled back). + */ + public static final int MANUAL_TIME_CORRECTION_MIN_MS = -5000; + public static final int MANUAL_TIME_CORRECTION_MAX_MS = 5000; + + /** + * Clamp a manual clock correction to + * [{@link #MANUAL_TIME_CORRECTION_MIN_MS}, {@link #MANUAL_TIME_CORRECTION_MAX_MS}] ms. + * + *

The live settings UI already clamps to this range before persisting + * ({@code TimeSyncSettings.apply} → {@code clampCorrectionMs}), but config + * hydration on every launch ({@code DatabaseOpr}'s {@code timeCorrectionMs} + * branch) re-applies the persisted value to {@code UtcTimer.delay} and must + * clamp with the same bounds. The reload path used to clamp to ±2000 + * while the UI allowed ±5000, so any correction beyond ±2 s was silently + * truncated back to 2 s at startup — leaving the operator's carefully-set + * offline clock offset wrong by up to 3 s and degrading decodes. Byte-identical + * for every in-range value. + */ + public static int clampManualTimeCorrectionMs(int ms) { + return Math.max(MANUAL_TIME_CORRECTION_MIN_MS, + Math.min(MANUAL_TIME_CORRECTION_MAX_MS, ms)); + } + public static int getFftWindowType() { return fftWindowType; } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 003878bc0..0110d6c93 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2677,7 +2677,10 @@ protected Void doInBackground(Void... voids) { } catch (NumberFormatException e) { ms = 0; } - ms = Math.max(-2000, Math.min(2000, ms)); + // Clamp with the SAME bounds the live settings UI uses when it + // persists this value (±5 s). The reload clamp used to be ±2 s, + // silently truncating any correction beyond ±2 s on every launch. + ms = GeneralVariables.clampManualTimeCorrectionMs(ms); GeneralVariables.manualTimeCorrectionMs = ms; UtcTimer.delay = ms; } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeCorrection.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeCorrection.kt index 4ccf61407..f8638a9f9 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeCorrection.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeCorrection.kt @@ -1,5 +1,6 @@ package radio.ks3ckc.ft8af.ui.settings +import com.k1af.ft8af.GeneralVariables import java.util.Locale import kotlin.math.abs import kotlin.math.roundToInt @@ -17,9 +18,18 @@ import kotlin.math.roundToInt * a Samsung A50 needing over 3 s while offline is why this is ±5 s, not ±2 s. */ -/** Inclusive bounds for the manual correction, in milliseconds. */ -internal const val TIME_CORRECTION_MIN_MS = -5000 -internal const val TIME_CORRECTION_MAX_MS = 5000 +/** + * Inclusive bounds for the manual correction, in milliseconds. + * + * Single source of truth is [GeneralVariables.MANUAL_TIME_CORRECTION_MIN_MS] / + * [GeneralVariables.MANUAL_TIME_CORRECTION_MAX_MS], which the config-hydration + * reload path clamps against ([GeneralVariables.clampManualTimeCorrectionMs]). + * These reference those so a range change can't drift the UI out of sync with + * reload. (Java `static final int` constant expressions inline at compile time, + * so this doesn't drag GeneralVariables' class init into this pure-logic file.) + */ +internal val TIME_CORRECTION_MIN_MS = GeneralVariables.MANUAL_TIME_CORRECTION_MIN_MS +internal val TIME_CORRECTION_MAX_MS = GeneralVariables.MANUAL_TIME_CORRECTION_MAX_MS /** Coerce an arbitrary correction into the allowed range. */ internal fun clampCorrectionMs(ms: Int): Int = diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/GeneralVariablesTimeCorrectionClampTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/GeneralVariablesTimeCorrectionClampTest.java new file mode 100644 index 000000000..d6ea366ff --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/GeneralVariablesTimeCorrectionClampTest.java @@ -0,0 +1,75 @@ +package com.k1af.ft8af; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** + * Guards {@link GeneralVariables#clampManualTimeCorrectionMs(int)} — the shared + * clamp for the manual clock correction (ms) applied to {@code UtcTimer.delay}. + * + *

Regression: the live settings UI ({@code TimeSyncSettings.apply} → + * {@code clampCorrectionMs}) clamps and persists this value to ±5000 ms, but the + * config-hydration reload path ({@code DatabaseOpr}'s {@code timeCorrectionMs} + * branch, run at every launch) used to clamp with a stale ±2000 ms bound. Any + * correction the operator set beyond ±2 s was therefore silently truncated back + * to 2 s on the next relaunch, leaving an offline (no-NTP) phone's clock offset + * wrong by up to 3 s — and incorrect PC/phone time is the single most common + * cause of FT8 decode failures. Centralizing the bound here and reusing it on the + * reload path keeps the two paths in lock-step. + * + *

Robolectric because {@link GeneralVariables} carries Android LiveData statics + * that must initialize before the static helper can be exercised. + */ +@RunWith(RobolectricTestRunner.class) +public class GeneralVariablesTimeCorrectionClampTest { + + @Test + public void inRangeValues_areReturnedUnchanged() { + // Byte-identical for every value the settings UI can produce. + assertThat(GeneralVariables.clampManualTimeCorrectionMs(0)).isEqualTo(0); + assertThat(GeneralVariables.clampManualTimeCorrectionMs(500)).isEqualTo(500); + assertThat(GeneralVariables.clampManualTimeCorrectionMs(-1500)).isEqualTo(-1500); + } + + @Test + public void correctionBeyondTwoSeconds_survivesTheReloadClamp() { + // The load-bearing regression: these all used to be truncated to ±2000. + assertThat(GeneralVariables.clampManualTimeCorrectionMs(3000)).isEqualTo(3000); + assertThat(GeneralVariables.clampManualTimeCorrectionMs(-3000)).isEqualTo(-3000); + assertThat(GeneralVariables.clampManualTimeCorrectionMs(4500)).isEqualTo(4500); + } + + @Test + public void boundaryValues_areKept() { + assertThat(GeneralVariables.clampManualTimeCorrectionMs( + GeneralVariables.MANUAL_TIME_CORRECTION_MAX_MS)) + .isEqualTo(GeneralVariables.MANUAL_TIME_CORRECTION_MAX_MS); + assertThat(GeneralVariables.clampManualTimeCorrectionMs( + GeneralVariables.MANUAL_TIME_CORRECTION_MIN_MS)) + .isEqualTo(GeneralVariables.MANUAL_TIME_CORRECTION_MIN_MS); + } + + @Test + public void outOfRangeValues_areClampedToTheBounds() { + assertThat(GeneralVariables.clampManualTimeCorrectionMs(6000)) + .isEqualTo(GeneralVariables.MANUAL_TIME_CORRECTION_MAX_MS); + assertThat(GeneralVariables.clampManualTimeCorrectionMs(-6000)) + .isEqualTo(GeneralVariables.MANUAL_TIME_CORRECTION_MIN_MS); + assertThat(GeneralVariables.clampManualTimeCorrectionMs(Integer.MAX_VALUE)) + .isEqualTo(GeneralVariables.MANUAL_TIME_CORRECTION_MAX_MS); + assertThat(GeneralVariables.clampManualTimeCorrectionMs(Integer.MIN_VALUE)) + .isEqualTo(GeneralVariables.MANUAL_TIME_CORRECTION_MIN_MS); + } + + @Test + public void bounds_matchTheLiveUiRangeAndAreSymmetric() { + // Must equal TimeCorrection.kt's authoritative ±5 s range (kept in sync by + // documentation). If a future edit narrows the reload bound again, this and + // the truncation test above fail. + assertThat(GeneralVariables.MANUAL_TIME_CORRECTION_MAX_MS).isEqualTo(5000); + assertThat(GeneralVariables.MANUAL_TIME_CORRECTION_MIN_MS).isEqualTo(-5000); + } +} From 15d9341c4598ab31368a9aade03a5a5860688deb Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 16:17:52 -0500 Subject: [PATCH 062/113] Add the two thermal controls the shipping UI was missing (#658) * Add the two thermal controls the shipping UI was missing A long portable session heats the phone until it browns out its own OTG accessory rail and drops the CAT link. Field log 2026-07-23: battery climbed 35.1C -> 48.6C over two hours with the pack sagging 4147mV -> 3725mV, zero USB drops in the first 90 minutes while it was cool, then twelve bus re-enumerations once it was hot - two of which left the rig keyed. The phone's two largest sustained loads during that session were both unadjustable: - FLAG_KEEP_SCREEN_ON was added unconditionally in onCreate, so a foreground session held the panel awake at outdoor brightness for its whole duration, with no setting. - Deep decode runs the subtract-and-redecode loop for up to 75% of every slot at full CPU (ModeProfile#deepDecodeBudgetMillis = max(2500, slot*0.75), i.e. 11.25s of every 15s FT8 slot; 301 deep passes that session). GeneralVariables.deepDecodeMode has always existed and the decoder honours it, but the fast/deep toggle only ever lived in the retired legacy assets/ConfigFragment.java - it was never ported to the Compose UI, so whatever sits in the config DB is what you get. Both are now toggles in a new POWER & HEAT section of Advanced settings. Screen-awake becomes GeneralVariables.keepScreenOn, persisted under a new "keepScreenOn" config key and defaulting to true, so an install that predates the setting behaves exactly as before. The flag application moves into ScreenWake.apply(window, keepOn): called from onCreate with the default (config has not hydrated that early), re-applied in a new onResume once the stored value is loaded, and applied live from the toggle so the effect is visible from the settings screen. RX keeps running with the screen off via RxForegroundService, so turning it off costs nothing but having to wake the phone to see the waterfall. Deep decode writes the same "deepMode" key the legacy fragment used, so an existing preference carries over untouched. The description states the trade-off plainly - it is worth real decodes (in the same session deep passes found the partner's reply in 31 cycles the fast pass missed), so this is a lever for hot days rather than a default to change. Neither of these stops the phone getting hot on its own; the hardware fix is powering the interface externally instead of off the phone's OTG rail. They make the two biggest software contributors adjustable. Tests: ScreenWake add/clear/idempotence/flag-mask, and config hydration for both keys including absent (keeps previous behaviour) and non-boolean values. Full suite 2481 pass. Installed and smoke-launched on a Pixel 8. Co-Authored-By: Claude Opus 4.8 (1M context) * Address review: re-apply keepScreenOn after async config load; null-safe hydration - ComposeMainActivity: observe mutableConfigLoaded and re-apply ScreenWake when config hydration completes. onCreate/onResume both run before initData()'s async load finishes, so a stored keepScreenOn=false would otherwise keep the screen awake until the next resume or a manual toggle. Delivered on the main thread, lifecycle-bound. - DatabaseOpr: hydrate deepMode/keepScreenOn with "1".equals(result) so a null config value (missing column from an imported backup) can't NPE. - strings_compose: mark settings_deep_decode_desc formatted="false" and use a single "75%" so the literal percent isn't shown doubled. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/GeneralVariables.java | 10 ++ .../com/k1af/ft8af/database/DatabaseOpr.java | 7 +- .../radio/ks3ckc/ft8af/ComposeMainActivity.kt | 26 +++++- .../kotlin/radio/ks3ckc/ft8af/ScreenWake.kt | 50 ++++++++++ .../ft8af/ui/settings/AdvancedSettings.kt | 54 +++++++++++ .../src/main/res/values/strings_compose.xml | 7 ++ .../DatabaseOprConfigHydrationTest.java | 77 ++++++++++++++++ .../radio/ks3ckc/ft8af/ScreenWakeTest.kt | 92 +++++++++++++++++++ 8 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ScreenWake.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ScreenWakeTest.kt diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index a2f82668d..dd6dc36dd 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -65,6 +65,16 @@ public class GeneralVariables { // time bound. public static boolean deepDecodeMode = true;//Whether deep decode mode is enabled + // Hold the screen awake while the app is in the foreground. On by default — + // that was the hard-coded behaviour before this became a setting — but a long + // portable session is the case where you want it off: an always-on panel at + // outdoor brightness is one of the two biggest heat sources on the phone, and + // a hot phone browns out its own OTG accessory rail (see the 2026-07-23 field + // log: 48.6C battery, twelve USB re-enumerations). RX keeps running with the + // screen off via RxForegroundService, so turning this off costs nothing but + // having to wake the phone to look at the waterfall. + public static boolean keepScreenOn = true; + public static boolean audioOutput32Bit = true;//Audio output type: true=float, false=int16 public static int audioSampleRate = 12000;//Transmit audio sample rate diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 0110d6c93..f90323651 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2965,7 +2965,12 @@ protected Void doInBackground(Void... voids) { GeneralVariables.usbAudioOutputProductId = parseConfigInt(result, 0); } if (name.equalsIgnoreCase("deepMode")) {//Deep decode mode - GeneralVariables.deepDecodeMode =result.equals("1"); + GeneralVariables.deepDecodeMode = "1".equals(result); + } + if (name.equalsIgnoreCase("keepScreenOn")) {//Hold the screen awake in foreground + // "1".equals(result), not result.equals("1"): a null config value + // (missing/blank column from an imported backup) must not NPE here. + GeneralVariables.keepScreenOn = "1".equals(result); } if (name.equalsIgnoreCase("debugModeEnabled")) {//Hidden debug screen unlock GeneralVariables.debugModeEnabled = result.equals("1"); diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt index e3ce079a6..4b57c99af 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt @@ -16,7 +16,6 @@ import android.os.Build import android.os.Bundle import android.util.Log import android.view.KeyEvent -import android.view.WindowManager import androidx.activity.OnBackPressedCallback import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity @@ -99,7 +98,11 @@ class ComposeMainActivity : AppCompatActivity() { // hiding the system bars immersively (transient reveal on swipe) instead // of the deprecated fullscreen flag. Keep the screen on as before. enableEdgeToEdge() - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + // Screen-awake is now a setting (default on, matching the previous + // hard-coded behaviour). Config has not hydrated yet at this point, so + // this applies the default; onResume re-applies once the stored value is + // loaded, and the settings toggle applies it live. + ScreenWake.apply(window, GeneralVariables.keepScreenOn) super.onCreate(savedInstanceState) @@ -136,6 +139,17 @@ class ComposeMainActivity : AppCompatActivity() { if (v != null) UsbAudioNative.setTxVolume(v) } + // Re-apply the screen-awake preference once config hydration finishes. + // onCreate/onResume both run before initData()'s async config load + // completes, so they apply the default (on); if the stored value is + // actually false the screen would stay awake until the next resume or a + // manual toggle. Observing mutableConfigLoaded catches the moment the real + // value lands. Lifecycle-bound and delivered on the main thread, so the + // window flag is set safely. + mainViewModel.mutableConfigLoaded.observe(this) { loaded -> + if (loaded == true) ScreenWake.apply(window, GeneralVariables.keepScreenOn) + } + // Register back press handler. Priority: dismiss the QSO sheet if // it's open, otherwise show exit confirm. Without this, back-from- // sheet tries to exit the whole app, which surprises users who @@ -617,6 +631,14 @@ class ComposeMainActivity : AppCompatActivity() { } } + override fun onResume() { + super.onResume() + // Re-apply the screen-awake preference. onCreate runs before config + // hydration, so the stored value may differ from the default it applied; + // this also covers returning from the settings screen. + ScreenWake.apply(window, GeneralVariables.keepScreenOn) + } + override fun onStop() { // A tune carrier must never outlive the operator's attention (issue // #408): backgrounding or swiping the app away mid-tune force-stops the diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ScreenWake.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ScreenWake.kt new file mode 100644 index 000000000..b71fb8785 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ScreenWake.kt @@ -0,0 +1,50 @@ +package radio.ks3ckc.ft8af + +import android.view.Window +import android.view.WindowManager + +/** + * Applies the "keep the screen awake" preference to a window. + * + * The flag used to be added unconditionally in [ComposeMainActivity.onCreate], so a + * foreground session held the panel on at whatever brightness for its entire + * duration. On a long portable run that is one of the two biggest heat sources on + * the phone (the other being the deep-decode loop), and a hot phone browns out its + * own OTG accessory rail — the 2026-07-23 field log shows the battery at 48.6C and + * the USB bus re-enumerating twelve times, twice leaving the rig keyed. + * + * Receive keeps running with the screen off via `RxForegroundService`, so this is a + * safe knob: turning it off costs nothing but having to wake the phone to look at + * the waterfall. + * + * Split out of the activity so the add/clear decision is unit-testable — the + * activity itself cannot be instantiated in a plain JVM test. + */ +object ScreenWake { + + /** + * Add or clear [WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON] on [window]. + * + * Idempotent: setting the same value twice is a no-op, so this can be called + * from `onCreate`, again after config hydration (which may flip it), and again + * whenever the user toggles the setting. + * + * @param keepOn the user's preference, i.e. `GeneralVariables.keepScreenOn` + */ + @JvmStatic + fun apply(window: Window, keepOn: Boolean) { + if (keepOn) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + /** + * Whether [flags] currently holds the screen awake. Exposed for the activity's + * own re-apply path and for tests. + */ + @JvmStatic + fun isHoldingScreenOn(flags: Int): Boolean = + (flags and WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) != 0 +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/AdvancedSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/AdvancedSettings.kt index 122e5c834..219e86090 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/AdvancedSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/AdvancedSettings.kt @@ -40,6 +40,7 @@ import com.k1af.ft8af.database.OnAfterQueryConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import radio.ks3ckc.ft8af.ScreenWake import radio.ks3ckc.ft8af.crash.CrashReporting import radio.ks3ckc.ft8af.theme.Accent import radio.ks3ckc.ft8af.theme.BgSurface2 @@ -160,6 +161,11 @@ fun AdvancedSettings( var lateStartMs by remember { mutableIntStateOf(GeneralVariables.lateStartTolerance) } var currentTheme by remember { mutableStateOf(loadTheme(context)) } + // Power & heat knobs. Seeded from GeneralVariables, which config hydration + // has already populated by the time settings can be opened. + var keepScreenOn by remember { mutableStateOf(GeneralVariables.keepScreenOn) } + var deepDecode by remember { mutableStateOf(GeneralVariables.deepDecodeMode) } + // Opt-in crash reporting (Sentry). Only surfaced when a DSN was compiled in. var crashReportingEnabled by remember { mutableStateOf(CrashReporting.isOptedIn(context)) } @@ -560,6 +566,54 @@ fun AdvancedSettings( } } + // ===================================================================== + // POWER & HEAT + // ===================================================================== + // Both knobs exist because a long portable session cooks the phone until + // it browns out its own OTG accessory rail and drops the CAT link (field + // log 2026-07-23: 48.6C battery, twelve USB re-enumerations). The screen + // flag used to be hard-coded on; deep decode was only reachable from the + // retired legacy settings fragment, so neither was adjustable in the + // shipping UI. + SettingsSection(title = stringResource(R.string.settings_section_power)) { + GlassCard(modifier = Modifier.fillMaxWidth()) { + Column { + SettingsRow( + label = stringResource(R.string.settings_keep_screen_on), + description = stringResource(R.string.settings_keep_screen_on_desc), + toggle = keepScreenOn, + onToggleChange = { on -> + keepScreenOn = on + GeneralVariables.keepScreenOn = on + mainViewModel.databaseOpr.writeConfig( + "keepScreenOn", if (on) "1" else "0", null, + ) + // Apply live rather than waiting for the next onResume, + // so the effect is visible from the settings screen. + (context as? android.app.Activity)?.let { + ScreenWake.apply(it.window, on) + } + }, + ) + SectionDivider() + SettingsRow( + label = stringResource(R.string.settings_deep_decode), + description = stringResource(R.string.settings_deep_decode_desc), + toggle = deepDecode, + onToggleChange = { on -> + deepDecode = on + GeneralVariables.deepDecodeMode = on + // Same "deepMode" key the retired legacy fragment wrote, + // so an existing preference carries over unchanged. + mainViewModel.databaseOpr.writeConfig( + "deepMode", if (on) "1" else "0", null, + ) + }, + ) + } + } + } + // ===================================================================== // APPEARANCE // ===================================================================== diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index ef33733af..fe482bf94 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -645,6 +645,13 @@ Delay before transmit to allow prior-cycle decode Late-start Tolerance Extra leading audio (ms) a late manual TX may clip so it still goes out this cycle instead of waiting for the next slot + + POWER & HEAT + Keep screen on + Hold the display awake while the app is open. Turn off for long portable sessions — receive keeps running in the background and the phone runs much cooler + Deep decode + Subtract-and-redecode pass that digs weak signals out from under strong ones. Uses up to 75% of every slot at full CPU — the biggest single heat source. Turn off to run cooler at the cost of some weak decodes Too late this cycle (need %d ms clip) — waiting for next slot diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java index f8294ecb6..b96c1e67f 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java @@ -52,6 +52,8 @@ public class DatabaseOprConfigHydrationTest { private int origIcomUdpPort; private float origVolumePercent; private int origAlcTargetLow; + private boolean origDeepDecodeMode; + private boolean origKeepScreenOn; @Before public void setUp() { @@ -67,6 +69,8 @@ public void setUp() { origIcomUdpPort = GeneralVariables.icomUdpPort; origVolumePercent = GeneralVariables.volumePercent; origAlcTargetLow = GeneralVariables.alcTargetLow; + origDeepDecodeMode = GeneralVariables.deepDecodeMode; + origKeepScreenOn = GeneralVariables.keepScreenOn; opr = new DatabaseOpr(ApplicationProvider.getApplicationContext(), null, null, 18); } @@ -88,6 +92,8 @@ public void tearDown() { GeneralVariables.icomUdpPort = origIcomUdpPort; GeneralVariables.volumePercent = origVolumePercent; GeneralVariables.alcTargetLow = origAlcTargetLow; + GeneralVariables.deepDecodeMode = origDeepDecodeMode; + GeneralVariables.keepScreenOn = origKeepScreenOn; } } @@ -224,4 +230,75 @@ public void nullUdpPort_doesNotCrashHydration() { GeneralVariables.udpPort = orig; } } + + // ---- power & heat toggles ------------------------------------------------ + // Both are surfaced in the Compose settings for the first time. deepMode + // reuses the key the retired legacy fragment wrote, so an existing preference + // must still hydrate; keepScreenOn is new and must default to the previous + // hard-coded behaviour (on) when absent. + + @Test + public void powerToggles_hydrateFromStoredValues() { + GeneralVariables.deepDecodeMode = true; + GeneralVariables.keepScreenOn = true; + + Map config = new LinkedHashMap<>(); + config.put("deepMode", "0"); + config.put("keepScreenOn", "0"); + opr.writeConfigSync(config); + + hydrate(); + + assertThat(GeneralVariables.deepDecodeMode).isFalse(); + assertThat(GeneralVariables.keepScreenOn).isFalse(); + } + + @Test + public void powerToggles_hydrateBackOn() { + GeneralVariables.deepDecodeMode = false; + GeneralVariables.keepScreenOn = false; + + Map config = new LinkedHashMap<>(); + config.put("deepMode", "1"); + config.put("keepScreenOn", "1"); + opr.writeConfigSync(config); + + hydrate(); + + assertThat(GeneralVariables.deepDecodeMode).isTrue(); + assertThat(GeneralVariables.keepScreenOn).isTrue(); + } + + @Test + public void keepScreenOn_absentFromConfigKeepsThePreviousBehaviour() { + // An install that predates the setting has no row; the screen must keep + // behaving as it did when the flag was hard-coded on. + GeneralVariables.keepScreenOn = true; + + Map config = new LinkedHashMap<>(); + config.put("deepMode", "1"); + opr.writeConfigSync(config); + + hydrate(); + + assertThat(GeneralVariables.keepScreenOn).isTrue(); + } + + @Test + public void powerToggles_nonBooleanValueReadsAsOff() { + // Hydration compares against "1", so anything else is off rather than a + // parse crash (the failure mode this test class exists for). + GeneralVariables.deepDecodeMode = true; + GeneralVariables.keepScreenOn = true; + + Map config = new LinkedHashMap<>(); + config.put("deepMode", ""); + config.put("keepScreenOn", "yes"); + opr.writeConfigSync(config); + + hydrate(); // must not throw + + assertThat(GeneralVariables.deepDecodeMode).isFalse(); + assertThat(GeneralVariables.keepScreenOn).isFalse(); + } } diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ScreenWakeTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ScreenWakeTest.kt new file mode 100644 index 000000000..a3296bc36 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ScreenWakeTest.kt @@ -0,0 +1,92 @@ +package radio.ks3ckc.ft8af + +import android.app.Activity +import android.view.WindowManager +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +/** + * Coverage for [ScreenWake], which applies the "keep screen on" preference. + * + * The flag used to be added unconditionally in `ComposeMainActivity.onCreate`, so + * a foreground session held the panel awake for its whole duration with no way to + * turn it off. That is one of the two biggest heat sources on the phone during a + * long portable run, and a hot phone browns out its own OTG accessory rail — + * field log 2026-07-23 shows 48.6C battery and twelve USB re-enumerations, two of + * which left the rig keyed. + * + * Robolectric: needs a real [android.view.Window] to add and clear flags on. + */ +@RunWith(RobolectricTestRunner::class) +class ScreenWakeTest { + + private fun window() = Robolectric.buildActivity(Activity::class.java).setup().get().window + + private fun holdsScreenOn(activityWindow: android.view.Window) = + ScreenWake.isHoldingScreenOn(activityWindow.attributes.flags) + + @Test + fun `apply true holds the screen awake`() { + val w = window() + ScreenWake.apply(w, true) + assertThat(holdsScreenOn(w)).isTrue() + } + + @Test + fun `apply false releases the screen`() { + val w = window() + ScreenWake.apply(w, true) + ScreenWake.apply(w, false) + assertThat(holdsScreenOn(w)).isFalse() + } + + @Test + fun `apply false on a window that never held it is a no-op`() { + // onCreate applies the default before config hydration; hydration may then + // apply false to a window that never had the flag. + val w = window() + ScreenWake.apply(w, false) + assertThat(holdsScreenOn(w)).isFalse() + } + + @Test + fun `repeated applies are idempotent`() { + // Called from onCreate, again from onResume, and again on every toggle. + val w = window() + ScreenWake.apply(w, true) + ScreenWake.apply(w, true) + assertThat(holdsScreenOn(w)).isTrue() + ScreenWake.apply(w, false) + ScreenWake.apply(w, false) + assertThat(holdsScreenOn(w)).isFalse() + } + + @Test + fun `toggling back on after off re-holds the screen`() { + val w = window() + ScreenWake.apply(w, false) + ScreenWake.apply(w, true) + assertThat(holdsScreenOn(w)).isTrue() + } + + @Test + fun `isHoldingScreenOn reads the flag bit`() { + assertThat(ScreenWake.isHoldingScreenOn(0)).isFalse() + assertThat( + ScreenWake.isHoldingScreenOn(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON), + ).isTrue() + // Must not be confused by other flags sharing the mask word. + assertThat( + ScreenWake.isHoldingScreenOn(WindowManager.LayoutParams.FLAG_FULLSCREEN), + ).isFalse() + assertThat( + ScreenWake.isHoldingScreenOn( + WindowManager.LayoutParams.FLAG_FULLSCREEN or + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, + ), + ).isTrue() + } +} From 4024a5702a54f1be42d4c23cdf4b99167ffbd98e Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 16:18:10 -0500 Subject: [PATCH 063/113] Fix web-logbook crash from racing the shared followCallsign list off-thread (#659) * Fix web-logbook crash from racing the shared followCallsign list off-thread GeneralVariables.followCallsign was a plain ArrayList mutated with no external lock from several threads: the decode/DB threads add to it (MainViewModel.addFollowCallsign, getFollowCallsignsFromDataBase) and the UI thread clears it (ClearCacheDataDialog), while the web logbook config page renders it on a NanoHTTPD worker thread (LogHttpServer). That page also auto-refreshes, so the render runs concurrently with the writers on every cycle. LogHttpServer and ClearCacheDataDialog both walked the list with a `for (i < followCallsign.size()) followCallsign.get(i)` index scan. That size()-then-get(i) is a TOCTOU: a concurrent clear() (or an add-driven resize) between the two calls throws IndexOutOfBoundsException, and reading a plain ArrayList with no happens-before against the writers can also tear the backing array. The result is a broken/500 web-logbook page (re-failing every 5 s refresh) and, worst case, corruption of a list the decode path relies on. Fix mirrors the already-hardened sibling GeneralVariables.transmitMessages: make followCallsign a CopyOnWriteArrayList so every add/clear is atomic, and have readers iterate the list with a for-each (which snapshots the COW array) instead of an index scan. The LogHttpServer loop is extracted into a pure, package-private followCallsignBlock(List) helper so the rendering (including the ten-per-row break) is unit-testable. Tests: - LogHttpServerFollowBlockTest: exact HTML for empty/single/10/11/20 callsigns, insertion order, and a concurrent add/clear-during-render stress that must never throw. - GeneralVariablesFollowCallsignTest (Robolectric): regression-locks the field as a CopyOnWriteArrayList and stresses concurrent add/clear during for-each. All app unit tests pass (:app:testDebugUnitTest). Co-Authored-By: Claude Opus 4.8 (1M context) * Address review: capture writer-thread failures in the stress tests Wrap the writer loops in try/catch and record failures via failure.compareAndSet(null, t) (first failure wins), in both LogHttpServerFollowBlockTest and GeneralVariablesFollowCallsignTest. A bare throw on the writer thread would otherwise be swallowed by the JVM and let the test pass even if background add/clear started throwing. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/GeneralVariables.java | 14 +- .../com/k1af/ft8af/html/LogHttpServer.java | 35 ++++- .../k1af/ft8af/ui/ClearCacheDataDialog.java | 6 +- .../GeneralVariablesFollowCallsignTest.java | 89 +++++++++++ .../html/LogHttpServerFollowBlockTest.java | 144 ++++++++++++++++++ 5 files changed, 278 insertions(+), 10 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/GeneralVariablesFollowCallsignTest.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/html/LogHttpServerFollowBlockTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index dd6dc36dd..9fe66dcf8 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -642,7 +642,19 @@ public static synchronized boolean directionalCQIsForMe(String callsignTo) { } - public static final ArrayList followCallsign = new ArrayList<>();//Followed callsigns + // The followed-callsigns list. Mutated concurrently with no external lock: + // the decode/DB threads add (MainViewModel.addFollowCallsign, + // getFollowCallsignsFromDataBase) and the UI thread clears it + // (ClearCacheDataDialog), while the web logbook renders it on a NanoHTTPD + // worker thread (LogHttpServer). A plain ArrayList corrupts its backing + // array / throws IndexOutOfBounds under that contention, so this is a + // CopyOnWriteArrayList: every add/clear is atomic. Readers must iterate the + // list itself (for-each snapshots the array) rather than size()+get(i), + // which can still race a concurrent clear. + // final: the thread-safety invariant depends on this always being the + // CopyOnWriteArrayList — never reassign it to a plain List. Mutate in place + // (add/clear) only. + public static final List followCallsign = new CopyOnWriteArrayList<>();//Followed callsigns // The calling-UI "followed entries" list. Mutated concurrently from three // threads with no external lock: the decode thread (findIncludedCallsigns diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java b/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java index b1bf5e6f4..4fab136da 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java @@ -923,13 +923,7 @@ private String showDebug() { , GeneralVariables.getStringFromResource(R.string.html_tracking_callsign))); result.append("

\n"); HtmlContext.tableEnd(result).append("\n"); @@ -945,6 +939,33 @@ private String showDebug() { return result.toString(); } + /** + * Render the followed-callsign block as comma-separated cells, ten per row. + * + *

This runs on a NanoHTTPD worker thread while the decode/DB threads add + * to and the UI thread clears {@link GeneralVariables#followCallsign}. It + * iterates the list with a for-each so a {@link java.util.concurrent.CopyOnWriteArrayList} + * hands back a stable array snapshot — never a {@code size()}/{@code get(i)} + * scan, which can race a concurrent clear into an + * {@link IndexOutOfBoundsException}. + * + * @param callsigns the followed callsigns (never mutated here) + * @return the inner HTML for the tracking-callsign table cell + */ + static String followCallsignBlock(java.util.List callsigns) { + StringBuilder result = new StringBuilder(); + int index = 0; + for (String callsign : callsigns) { + result.append(callsign); + result.append(", "); + if (((index + 1) % 10) == 0) { + result.append("

\n"); + return sb.toString(); + } + /** * The request path segment at {@code index} (from {@code getUri().split("/")}), * or {@code null} when the request has no such segment. @@ -907,15 +941,7 @@ private String showDebug() { , String.format(GeneralVariables.getStringFromResource(R.string.html_successful_callsign) , GeneralVariables.getBandString()))); - result.append("\n"); + result.append(successfulCallsignBlock(GeneralVariables.QSL_Callsign_list)); HtmlContext.tableEnd(result).append("
\n"); HtmlContext.tableBegin(result, false, 0, true).append("\n"); diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/GetAllQSLCallsignModeTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/GetAllQSLCallsignModeTest.java index 2bbde31d1..827f5420a 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/database/GetAllQSLCallsignModeTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/GetAllQSLCallsignModeTest.java @@ -84,6 +84,18 @@ public void sameModeOffLoadsAllModes() { .containsExactly("TODAY_FT8", "TODAY_FT4"); } + @Test + public void loadPublishesCopyOnWriteList() { + DatabaseOpr.GetAllQSLCallsign.get(db); + + // The worked list is read from decode/UI threads and the NanoHTTPD web-logbook worker + // while this background reload swaps it wholesale; publishing a CopyOnWriteArrayList (not + // a plain ArrayList) is what keeps those readers on a stable snapshot. See the field note + // in GeneralVariables and LogHttpServer.successfulCallsignBlock. + assertThat(GeneralVariables.QSL_Callsign_list) + .isInstanceOf(java.util.concurrent.CopyOnWriteArrayList.class); + } + @Test public void sameModeOnDropsOtherModeAcrossAllLists() { GeneralVariables.workedSameMode = true; diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/html/LogHttpServerSuccessfulCallsignBlockTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/html/LogHttpServerSuccessfulCallsignBlockTest.java new file mode 100644 index 000000000..9e0851749 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/html/LogHttpServerSuccessfulCallsignBlockTest.java @@ -0,0 +1,127 @@ +package com.k1af.ft8af.html; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Pure-logic coverage for {@link LogHttpServer#successfulCallsignBlock(List)}, the + * "successfully contacted callsigns" cell block on the web-logbook debug page. + * + *

The block used to iterate the shared {@link com.k1af.ft8af.GeneralVariables#QSL_Callsign_list} + * inline with {@code for (i < list.size()) list.get(i)}, re-reading the static field on every + * {@code size()}/{@code get(i)}. A background DB reload swaps that list wholesale, so a swap to a + * shorter list between the two reads threw {@link IndexOutOfBoundsException} mid-render. The block + * now takes a single snapshot reference, and the field is a CopyOnWriteArrayList, so neither a + * ref-swap nor a concurrent in-place add() can tear the iteration. These tests pin the exact HTML + * shape and prove the render survives a concurrent writer. No Android types are touched, so no + * Robolectric runner is needed. + */ +public class LogHttpServerSuccessfulCallsignBlockTest { + + @Test + public void emptyList_rendersJustTheWrappingCell() { + assertThat(LogHttpServer.successfulCallsignBlock(new ArrayList<>())) + .isEqualTo("

\n"); + } + + @Test + public void singleCallsign_appendsSeparator() { + assertThat(LogHttpServer.successfulCallsignBlock(List.of("K1ABC"))) + .isEqualTo("\n"); + } + + @Test + public void tenthCallsign_startsANewRow() { + List calls = new ArrayList<>(); + for (int i = 1; i <= 10; i++) { + calls.add("C" + i); + } + String html = LogHttpServer.successfulCallsignBlock(calls); + + // The row break fires after the 10th entry (i + 1 == 10); since that's the last entry the + // block closes with the (pre-existing) trailing empty row. Ten separators, one per call. + assertThat(countOccurrences(html, "\n"); + assertThat(countOccurrences(html, " ")).isEqualTo(10); + } + + @Test + public void wrapsEveryTenCallsigns() { + List calls = new ArrayList<>(); + for (int i = 1; i <= 25; i++) { + calls.add("C" + i); + } + // Row breaks after the 10th and 20th entries only (not the 25th, mid-row). + assertThat(countOccurrences( + LogHttpServer.successfulCallsignBlock(calls), + "\n"); + } + } catch (Throwable t) { + failure.set(t); + } finally { + stop.set(true); + writer.join(); + } + + assertThat(failure.get()).isNull(); + } + + private static int countOccurrences(String haystack, String needle) { + int count = 0; + int from = 0; + while (true) { + int at = haystack.indexOf(needle, from); + if (at < 0) { + return count; + } + count++; + from = at + needle.length(); + } + } +} From 7e4af46469ea0924e500522bc2c96c0a2c7f5302 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 18:27:15 -0500 Subject: [PATCH 066/113] Add a "New Grid" filter to the decode list for grid chasers (#670) Grid-square hunting (VUCC, the Fred Fish memorial, chasing rare grids) is one of the most popular things operators do on FT8, but the decode list had no way to focus on it. The row already flags an unworked grid with a NEW GRID pill, yet the only award-oriented filter chip was "New DXCC". This adds a symmetric "New Grid" chip: it keeps only CQ stations whose Maidenhead grid field the operator hasn't logged yet, turning the decode list into a one-tap "who's calling from a grid I still need" view. - Extract the "is this an unworked grid?" test the row already computed inline into a shared `isNewGridStation()` helper, so the NEW GRID pill and the new filter chip can never disagree on what counts as new. - Wire the chip through the filter option list, localized label, filter predicate, and a dedicated empty state ("No new grids"). Tests: filter-level cases (keeps unworked-grid CQs, drops already-worked grids, drops directed/grid-less frames), the shared predicate (4-char requirement, case/sub-square insensitivity, worked vs. new), the NEW_GRID pill wiring, and the empty-state render. All green via `:app:testDebugUnitTest`. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt | 20 +++- .../ks3ckc/ft8af/ui/decode/DecodeScreen.kt | 9 +- .../src/main/res/values/strings_compose.xml | 3 + .../ft8af/ui/decode/DecodeFilterTest.kt | 56 ++++++++++- .../ft8af/ui/decode/DecodeScreenTest.kt | 11 +++ .../ks3ckc/ft8af/ui/decode/NewGridTest.kt | 92 +++++++++++++++++++ 6 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/NewGridTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt index 3fe4722be..58e361a57 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt @@ -338,6 +338,21 @@ private fun MetaText(text: String) { ) } +/** + * Whether [message] carries a Maidenhead grid field the operator hasn't logged + * yet — i.e. a "new grid" for grid-chasing (VUCC). Requires a full 4-character + * field (the two-letter field + two-digit square that `checkQSLGrid` keys on); + * bare-callsign or sub-square-only frames don't count. Shared by the row's + * NEW_GRID highlight and the Decode screen's "New Grid" filter so the pill and + * the chip always agree on what counts as new. + */ +internal fun isNewGridStation(message: Ft8Message): Boolean { + val grid = message.maidenGrid + return !grid.isNullOrEmpty() && + grid.length >= 4 && + !GeneralVariables.checkQSLGrid(grid) +} + /** * Resolve the [QsoStatus] for a given [Ft8Message] based on its state. * @@ -357,11 +372,8 @@ internal fun resolveQsoStatus(message: Ft8Message): QsoStatus? { GeneralVariables.checkQSLCallsign(message.getCallsignFrom()) val isToMe = GeneralVariables.checkIsMyCallsign(message.callsignTo ?: "") val modifier = message.modifier - val grid = message.maidenGrid - val newGrid = !grid.isNullOrEmpty() && - grid.length >= 4 && - !GeneralVariables.checkQSLGrid(grid) + val newGrid = isNewGridStation(message) val newBand = !isWorked && GeneralVariables.checkQSLCallsign_OtherBand(message.callsignFrom ?: "") diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt index 062f8a8e9..acb09e928 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt @@ -67,7 +67,7 @@ fun DecodeScreen( // Filter state. Backed by the ViewModel so the chosen filter survives // navigation away from Decode and back (the screen is recreated by the // tab switch, which would otherwise reset a local rememberSaveable). - val filterOptions = listOf("All", "CQ Calls", "CQ POTA", "New DXCC", "Needed", "For Me") + val filterOptions = listOf("All", "CQ Calls", "CQ POTA", "New DXCC", "New Grid", "Needed", "For Me") val selectedFilter by mainViewModel.decodeFilter.observeAsState("All") // Couple the "CQ POTA" display filter to Hunt: while it's selected, the auto-call @@ -522,6 +522,7 @@ private fun filterLabel(key: String): String = when (key) { "CQ Calls" -> stringResource(R.string.decode_filter_cq_calls) "CQ POTA" -> stringResource(R.string.decode_filter_cq_pota) "New DXCC" -> stringResource(R.string.decode_filter_new_dxcc) + "New Grid" -> stringResource(R.string.decode_filter_new_grid) "Needed" -> stringResource(R.string.decode_filter_needed) "For Me" -> stringResource(R.string.decode_filter_for_me) else -> stringResource(R.string.decode_filter_all) @@ -538,6 +539,7 @@ private fun filterLabel(key: String): String = when (key) { * - All: no filtering * - CQ Calls: only CQ messages * - New DXCC: CQ from a DXCC entity not yet in the operator's worked list + * - New Grid: CQ from a Maidenhead grid field not yet in the operator's worked list * - Needed: need QSL confirmation (not in QSL callsign list) * - For Me: callsignTo matches operator's callsign */ @@ -586,6 +588,10 @@ internal fun filterMessages( // this filter and hunting agree on who counts as a POTA station — see issue #333. "CQ POTA" -> base.filter { radio.ks3ckc.ft8af.pota.PotaCqClassifier.isPotaCq(it) } "New DXCC" -> base.filter { it.checkIsCQ() && it.fromDxcc } + // Mirror of "New DXCC" for grid chasers (VUCC / grid hunting): only CQ + // stations whose grid field the operator hasn't logged yet, so the list + // becomes a one-tap "who's calling from a grid I still need" view. + "New Grid" -> base.filter { it.checkIsCQ() && isNewGridStation(it) } "Needed" -> base.filter { !it.isQSL_Callsign && !GeneralVariables.checkQSLCallsign(it.callsignFrom ?: "") @@ -610,6 +616,7 @@ internal fun EmptyState( "CQ Calls" -> stringResource(R.string.decode_empty_cq_title) to stringResource(R.string.decode_empty_cq_body) "CQ POTA" -> stringResource(R.string.decode_empty_pota_title) to stringResource(R.string.decode_empty_pota_body) "New DXCC" -> stringResource(R.string.decode_empty_dxcc_title) to stringResource(R.string.decode_empty_dxcc_body) + "New Grid" -> stringResource(R.string.decode_empty_grid_title) to stringResource(R.string.decode_empty_grid_body) "Needed" -> stringResource(R.string.decode_empty_needed_title) to stringResource(R.string.decode_empty_needed_body) "For Me" -> stringResource(R.string.decode_empty_forme_title) to stringResource(R.string.decode_empty_forme_body) else -> stringResource(R.string.decode_empty_default_title) to stringResource(R.string.decode_empty_default_body, GeneralVariables.currentMode().displayName) diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index fe482bf94..4e6ba8396 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -174,6 +174,7 @@ CQ Calls CQ POTA New DXCC + New Grid Needed For Me No CQ calls @@ -182,6 +183,8 @@ No park activations decoded on this band yet — open POTA → Hunt for the spot list. No new DXCC No unworked DXCC entities have been decoded yet. + No new grids + No stations calling from an unworked grid square have been decoded yet. Nothing needed No stations needing confirmation found. No calls for you diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeFilterTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeFilterTest.kt index e57777d67..3800b3b6e 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeFilterTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeFilterTest.kt @@ -1,7 +1,10 @@ package radio.ks3ckc.ft8af.ui.decode import com.k1af.ft8af.Ft8Message +import com.k1af.ft8af.GeneralVariables import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -10,9 +13,10 @@ import org.robolectric.RobolectricTestRunner * Unit-tests the pure [filterMessages] decode-list filter. Robolectric is * needed only because [Ft8Message] reaches android.util.Log on construction. * - * These exercise the two chip filters that depend purely on message content - * (no operator-specific GeneralVariables state, which stays at its defaults - * here): "All" passes everything through, "CQ Calls" keeps only CQ messages. + * These exercise the chip filters that depend on message content plus the + * operator's worked-grid list (kept at its defaults except where a test seeds + * it explicitly): "All" passes everything through, "CQ Calls" keeps only CQ + * messages, and "New Grid" keeps only CQ stations from an unworked grid. */ @RunWith(RobolectricTestRunner::class) class DecodeFilterTest { @@ -20,6 +24,19 @@ class DecodeFilterTest { private fun cq(from: String) = Ft8Message("CQ", from, "FN42") private fun directed(to: String, from: String) = Ft8Message(to, from, "FN42") + /** A CQ message carrying a decoded Maidenhead grid (the field the filter keys on). */ + private fun cqFromGrid(from: String, grid: String) = cq(from).apply { maidenGrid = grid } + + @Before + fun clearWorkedGrids() { + GeneralVariables.QSL_Grid_list.clear() + } + + @After + fun tearDown() { + GeneralVariables.QSL_Grid_list.clear() + } + @Test fun all_returnsEveryMessage() { val messages = listOf(cq("K1ABC"), directed("W1AW", "K2DEF")) @@ -39,4 +56,37 @@ class DecodeFilterTest { assertThat(result).containsExactly(cqMsg) } + + @Test + fun newGrid_keepsCqFromUnworkedGrid() { + // No grids worked yet, so every 4-char grid counts as new. + val cqMsg = cqFromGrid("K1ABC", "FN42") + + val result = filterMessages(listOf(cqMsg), "New Grid") + + assertThat(result).containsExactly(cqMsg) + } + + @Test + fun newGrid_dropsCqFromAlreadyWorkedGrid() { + GeneralVariables.QSL_Grid_list.add("FN42") + val worked = cqFromGrid("K1ABC", "FN42") // grid already logged + val fresh = cqFromGrid("K2DEF", "EN37") // still needed + + val result = filterMessages(listOf(worked, fresh), "New Grid") + + assertThat(result).containsExactly(fresh) + } + + @Test + fun newGrid_dropsDirectedAndGridlessMessages() { + val directedNewGrid = directed("W1AW", "K2DEF").apply { maidenGrid = "EN37" } + val cqNoGrid = cq("K3GHI").apply { maidenGrid = null } + + val result = filterMessages(listOf(directedNewGrid, cqNoGrid), "New Grid") + + // Directed messages aren't callable CQs; a CQ without a grid can't be + // judged new — both are excluded. + assertThat(result).isEmpty() + } } diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreenTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreenTest.kt index 8c2781dd6..3b93c8aef 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreenTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreenTest.kt @@ -55,6 +55,17 @@ class DecodeScreenTest { composeRule.onNodeWithText(title).assertIsDisplayed() } + @Test + fun newGridFilter_showsGridEmptyCopy() { + val title = context.getString(R.string.decode_empty_grid_title) + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + EmptyState(selectedFilter = "New Grid") + } + + composeRule.onNodeWithText(title).assertIsDisplayed() + } + @Test fun sortLabel_showsActiveModeTextAndDescription() { composeRule.setContent { diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/NewGridTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/NewGridTest.kt new file mode 100644 index 000000000..1f57f0d1f --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/NewGridTest.kt @@ -0,0 +1,92 @@ +package radio.ks3ckc.ft8af.ui.decode + +import com.k1af.ft8af.Ft8Message +import com.k1af.ft8af.GeneralVariables +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import radio.ks3ckc.ft8af.ui.components.QsoStatus + +/** + * Unit-tests the shared [isNewGridStation] predicate that backs both the + * decode row's NEW_GRID pill and the "New Grid" decode filter, so the two never + * disagree on what counts as an unworked grid. + * + * Robolectric is needed only because [Ft8Message] reaches android.util.Log on + * construction; the logic under test is pure. + */ +@RunWith(RobolectricTestRunner::class) +class NewGridTest { + + private var savedHighlightPota = false + private var savedHighlightNewDxcc = false + private var savedHighlightNewGrid = false + private var savedHighlightNewBand = false + private var savedHighlightWorked = false + + @Before + fun setUp() { + savedHighlightPota = GeneralVariables.highlightPota + savedHighlightNewDxcc = GeneralVariables.highlightNewDxcc + savedHighlightNewGrid = GeneralVariables.highlightNewGrid + savedHighlightNewBand = GeneralVariables.highlightNewBand + savedHighlightWorked = GeneralVariables.highlightWorked + GeneralVariables.QSL_Grid_list.clear() + GeneralVariables.QSL_Callsign_list.clear() + } + + @After + fun tearDown() { + GeneralVariables.highlightPota = savedHighlightPota + GeneralVariables.highlightNewDxcc = savedHighlightNewDxcc + GeneralVariables.highlightNewGrid = savedHighlightNewGrid + GeneralVariables.highlightNewBand = savedHighlightNewBand + GeneralVariables.highlightWorked = savedHighlightWorked + GeneralVariables.QSL_Grid_list.clear() + GeneralVariables.QSL_Callsign_list.clear() + } + + private fun cqFromGrid(from: String, grid: String?): Ft8Message = + Ft8Message("CQ", from, "TEST").apply { maidenGrid = grid } + + @Test + fun unworkedFourCharGrid_isNew() { + assertThat(isNewGridStation(cqFromGrid("K1ABC", "FN42"))).isTrue() + } + + @Test + fun workedGrid_isNotNew() { + GeneralVariables.QSL_Grid_list.add("FN42") + assertThat(isNewGridStation(cqFromGrid("K1ABC", "FN42"))).isFalse() + } + + @Test + fun workedGridMatchIsCaseAndSubSquareInsensitive() { + // checkQSLGrid keys on the upper-cased 4-char field, so a 6-char decode + // from the same square is still "worked". + GeneralVariables.QSL_Grid_list.add("FN42") + assertThat(isNewGridStation(cqFromGrid("K1ABC", "fn42ab"))).isFalse() + } + + @Test + fun missingOrTooShortGrid_isNotNew() { + assertThat(isNewGridStation(cqFromGrid("K1ABC", null))).isFalse() + assertThat(isNewGridStation(cqFromGrid("K1ABC", ""))).isFalse() + assertThat(isNewGridStation(cqFromGrid("K1ABC", "FN"))).isFalse() + } + + @Test + fun newGridPill_surfacesWhenHighlightEnabled() { + GeneralVariables.highlightPota = false + GeneralVariables.highlightNewDxcc = false + GeneralVariables.highlightNewGrid = true + GeneralVariables.highlightNewBand = false + GeneralVariables.highlightWorked = false + + assertThat(resolveQsoStatus(cqFromGrid("K1ABC", "FN42"))) + .isEqualTo(QsoStatus.NEW_GRID) + } +} From 1a9ada2cc572e6641dc3485c5754808479d20880 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 18:28:18 -0500 Subject: [PATCH 067/113] Fix FT-450/DX10/Wolf-SDR CAT: SWR meter dead during TX (coalesced reply dropped) (#669) The FT-450, DX10-series and Wolf-SDR handlers share the same one-command- per-read reassembly the FT-891 (#665) and FT-2000 (#667) fixes already corrected: onReceiveData parsed only the FIRST ';'-terminated command in a read and re-buffered the remainder WITH its retained terminator. The meter poll sends the ALC and SWR reads back-to-back (setRead39Meters_ALC() then _SWR()), so the transport routinely coalesces both replies ("RM4nnn;RM6nnn;") into one read. The old parse consumed only the ALC frame; the next poll's clearBufferData() then wiped the re-buffered SWR frame, so the SWR reading was permanently lost and the high-SWR safety alert never fired during TX. The retained ';' also poisoned the following read's parse. Fix: drain every complete command per read via the shared CatLineSplitter (introduced by #665, the text sibling of CivFrameSplitter), carrying only the unterminated tail into the next call. Per-command handling is extracted verbatim into processCommand so onReceiveData stays a thin drain loop; this also consolidates three byte-identical copies onto the shared splitter and collapses a latent double getFrequency() call. No protocol/behavior change beyond no longer dropping coalesced replies. Tests: added a CatLineSplitterTest case modelling the gen-3 meter poll (coalesced ALC+partial-SWR read then the SWR tail) proving the SWR frame survives. Full :app:testDebugUnitTest green. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/rigs/Wolf_sdr_450Rig.java | 78 +++++++++--------- .../com/k1af/ft8af/rigs/Yaesu38_450Rig.java | 78 +++++++++--------- .../com/k1af/ft8af/rigs/YaesuDX10Rig.java | 79 ++++++++++--------- .../k1af/ft8af/rigs/CatLineSplitterTest.java | 17 ++++ 4 files changed, 135 insertions(+), 117 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Wolf_sdr_450Rig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Wolf_sdr_450Rig.java index 4294c438a..defdffc02 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Wolf_sdr_450Rig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Wolf_sdr_450Rig.java @@ -141,50 +141,50 @@ public void setFreqToRig() { @Override public void onReceiveData(byte[] data) { - String s = new String(data); - //ToastMessage.showDebug("39 YAESU read data:"+new String(Yaesu3RigConstant.setReadOperationFreq())); + // Drain every complete ';'-terminated command in this read. The meter + // poll sends the ALC and SWR reads back-to-back, so the transport + // routinely coalesces both replies ("RM4nnn;RM6nnn;") into one chunk. + // The old parser consumed only the first command and re-buffered the + // rest with its terminator retained, where the next poll's + // clearBufferData() wiped it -- permanently losing the SWR reading (so + // the high-SWR safety alert never fires during TX). Only the trailing, + // unterminated fragment is carried into the next call. Shared with the + // other Yaesu gen-3 rigs via CatLineSplitter. + CatLineSplitter.Result result = CatLineSplitter.split(buffer.toString(), new String(data), ';'); + clearBufferData(); + buffer.append(result.remainder); + // A runaway unterminated buffer must not grow without bound. + if (buffer.length() > 1000) clearBufferData(); + + for (String command : result.frames) { + processCommand(command); + } + } - if (!s.contains(";")) { - buffer.append(s); - if (buffer.length() > 1000) clearBufferData(); - //return;//data reception not yet complete. - } else { - if (s.indexOf(";") > 0) {//received end-of-data, and delimiter is not the first character - buffer.append(s.substring(0, s.indexOf(";"))); + /** + * Parse and act on one complete ';'-terminated command (terminator already + * stripped). Extracted so {@link #onReceiveData} stays a thin drain loop. + */ + private void processCommand(String command) { + Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(command); + if (yaesu3Command == null) { + return; + } + if (yaesu3Command.getCommandID().equalsIgnoreCase("FA") + || yaesu3Command.getCommandID().equalsIgnoreCase("FB")) { + long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); + if (tempFreq != 0) {//if tempFreq==0, frequency is invalid + setFreq(tempFreq); } - - //begin parsing data - Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(buffer.toString()); - clearBufferData();//clear buffer - //put remaining data into buffer - buffer.append(s.substring(s.indexOf(";") + 1)); - - if (yaesu3Command == null) { - return; + } else if (yaesu3Command.getCommandID().equalsIgnoreCase("RM")) {//METER + if (Yaesu3Command.isSWRMeter38(yaesu3Command)) { + swr = Yaesu3Command.getALCOrSWR38(yaesu3Command); } - //long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); - //if (tempFreq != 0) {//if tempFreq==0, frequency is invalid - // setFreq(Yaesu3Command.getFrequency(yaesu3Command)); - //} - - if (yaesu3Command.getCommandID().equalsIgnoreCase("FA") - || yaesu3Command.getCommandID().equalsIgnoreCase("FB")) { - long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); - if (tempFreq != 0) {//if tempFreq==0, frequency is invalid - setFreq(Yaesu3Command.getFrequency(yaesu3Command)); - } - } else if (yaesu3Command.getCommandID().equalsIgnoreCase("RM")) {//METER - if (Yaesu3Command.isSWRMeter38(yaesu3Command)) { - swr = Yaesu3Command.getALCOrSWR38(yaesu3Command); - } - if (Yaesu3Command.isALCMeter38(yaesu3Command)) { - alc = Yaesu3Command.getALCOrSWR38(yaesu3Command); - } - showAlert(); + if (Yaesu3Command.isALCMeter38(yaesu3Command)) { + alc = Yaesu3Command.getALCOrSWR38(yaesu3Command); } - + showAlert(); } - } @Override diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu38_450Rig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu38_450Rig.java index 4227a832f..3a23051ec 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu38_450Rig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu38_450Rig.java @@ -131,50 +131,50 @@ public void setFreqToRig() { @Override public void onReceiveData(byte[] data) { - String s = new String(data); - //ToastMessage.showDebug("39 YAESU read data:"+new String(Yaesu3RigConstant.setReadOperationFreq())); + // Drain every complete ';'-terminated command in this read. The meter + // poll sends the ALC and SWR reads back-to-back, so the transport + // routinely coalesces both replies ("RM4nnn;RM6nnn;") into one chunk. + // The old parser consumed only the first command and re-buffered the + // rest with its terminator retained, where the next poll's + // clearBufferData() wiped it -- permanently losing the SWR reading (so + // the high-SWR safety alert never fires during TX). Only the trailing, + // unterminated fragment is carried into the next call. Shared with the + // other Yaesu gen-3 rigs via CatLineSplitter. + CatLineSplitter.Result result = CatLineSplitter.split(buffer.toString(), new String(data), ';'); + clearBufferData(); + buffer.append(result.remainder); + // A runaway unterminated buffer must not grow without bound. + if (buffer.length() > 1000) clearBufferData(); + + for (String command : result.frames) { + processCommand(command); + } + } - if (!s.contains(";")) { - buffer.append(s); - if (buffer.length() > 1000) clearBufferData(); - //return;//data reception not yet complete. - } else { - if (s.indexOf(";") > 0) {//received end-of-data, and delimiter is not the first character - buffer.append(s.substring(0, s.indexOf(";"))); + /** + * Parse and act on one complete ';'-terminated command (terminator already + * stripped). Extracted so {@link #onReceiveData} stays a thin drain loop. + */ + private void processCommand(String command) { + Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(command); + if (yaesu3Command == null) { + return; + } + if (yaesu3Command.getCommandID().equalsIgnoreCase("FA") + || yaesu3Command.getCommandID().equalsIgnoreCase("FB")) { + long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); + if (tempFreq != 0) {//if tempFreq==0, frequency is invalid + setFreq(tempFreq); } - - //begin parsing data - Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(buffer.toString()); - clearBufferData();//clear buffer - //put remaining data into buffer - buffer.append(s.substring(s.indexOf(";") + 1)); - - if (yaesu3Command == null) { - return; + } else if (yaesu3Command.getCommandID().equalsIgnoreCase("RM")) {//METER + if (Yaesu3Command.isSWRMeter38(yaesu3Command)) { + swr = Yaesu3Command.getALCOrSWR38(yaesu3Command); } - //long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); - //if (tempFreq != 0) {//if tempFreq==0, frequency is invalid - // setFreq(Yaesu3Command.getFrequency(yaesu3Command)); - //} - - if (yaesu3Command.getCommandID().equalsIgnoreCase("FA") - || yaesu3Command.getCommandID().equalsIgnoreCase("FB")) { - long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); - if (tempFreq != 0) {//if tempFreq==0, frequency is invalid - setFreq(Yaesu3Command.getFrequency(yaesu3Command)); - } - } else if (yaesu3Command.getCommandID().equalsIgnoreCase("RM")) {//METER - if (Yaesu3Command.isSWRMeter38(yaesu3Command)) { - swr = Yaesu3Command.getALCOrSWR38(yaesu3Command); - } - if (Yaesu3Command.isALCMeter38(yaesu3Command)) { - alc = Yaesu3Command.getALCOrSWR38(yaesu3Command); - } - showAlert(); + if (Yaesu3Command.isALCMeter38(yaesu3Command)) { + alc = Yaesu3Command.getALCOrSWR38(yaesu3Command); } - + showAlert(); } - } @Override diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/YaesuDX10Rig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/YaesuDX10Rig.java index 8997a9de4..7e7f7c607 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/YaesuDX10Rig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/YaesuDX10Rig.java @@ -144,49 +144,50 @@ public void setFreqToRig() { @Override public void onReceiveData(byte[] data) { - String s = new String(data); - if (!s.contains(";")) { - buffer.append(s); - if (buffer.length() > 1000) clearBufferData(); - return;//data reception not yet complete. - } else { - if (s.indexOf(";") > 0) {//received end-of-data, and delimiter is not the first character - buffer.append(s.substring(0, s.indexOf(";"))); - } - //begin parsing data - Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(buffer.toString()); - clearBufferData();//clear buffer - //put remaining data into buffer - buffer.append(s.substring(s.indexOf(";") + 1)); + // Drain every complete ';'-terminated command in this read. The meter + // poll sends the ALC and SWR reads back-to-back, so the transport + // routinely coalesces both replies ("RM4nnn;RM6nnn;") into one chunk. + // The old parser consumed only the first command and re-buffered the + // rest with its terminator retained, where the next poll's + // clearBufferData() wiped it -- permanently losing the SWR reading (so + // the high-SWR safety alert never fires during TX). Only the trailing, + // unterminated fragment is carried into the next call. Shared with the + // other Yaesu gen-3 rigs via CatLineSplitter. + CatLineSplitter.Result result = CatLineSplitter.split(buffer.toString(), new String(data), ';'); + clearBufferData(); + buffer.append(result.remainder); + // A runaway unterminated buffer must not grow without bound. + if (buffer.length() > 1000) clearBufferData(); + + for (String command : result.frames) { + processCommand(command); + } + } - if (yaesu3Command == null) { - return; + /** + * Parse and act on one complete ';'-terminated command (terminator already + * stripped). Extracted so {@link #onReceiveData} stays a thin drain loop. + */ + private void processCommand(String command) { + Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(command); + if (yaesu3Command == null) { + return; + } + if (yaesu3Command.getCommandID().equalsIgnoreCase("FA") + || yaesu3Command.getCommandID().equalsIgnoreCase("FB")) { + long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); + if (tempFreq != 0) {//if tempFreq==0, frequency is invalid + setFreq(tempFreq); } - //if (yaesu3Command.getCommandID().equalsIgnoreCase("FA") - // || yaesu3Command.getCommandID().toLowerCase().equals("FB")) { - // long tempFreq=Yaesu3Command.getFrequency(yaesu3Command); - // if (tempFreq!=0) {//if tempFreq==0, frequency is invalid - // setFreq(Yaesu3Command.getFrequency(yaesu3Command)); - // } - //} - if (yaesu3Command.getCommandID().equalsIgnoreCase("FA") - || yaesu3Command.getCommandID().equalsIgnoreCase("FB")) { - long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); - if (tempFreq != 0) {//if tempFreq==0, frequency is invalid - setFreq(Yaesu3Command.getFrequency(yaesu3Command)); - } - } else if (yaesu3Command.getCommandID().equalsIgnoreCase("RM")) {//METER - if (Yaesu3Command.isSWRMeter38(yaesu3Command)) { - swr = Yaesu3Command.getALCOrSWR38(yaesu3Command); - } - if (Yaesu3Command.isALCMeter38(yaesu3Command)) { - alc = Yaesu3Command.getALCOrSWR38(yaesu3Command); - } - showAlert(); + } else if (yaesu3Command.getCommandID().equalsIgnoreCase("RM")) {//METER + if (Yaesu3Command.isSWRMeter38(yaesu3Command)) { + swr = Yaesu3Command.getALCOrSWR38(yaesu3Command); } - + if (Yaesu3Command.isALCMeter38(yaesu3Command)) { + alc = Yaesu3Command.getALCOrSWR38(yaesu3Command); + } + showAlert(); } - } @Override diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java index ed119cc68..b06ecff44 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java @@ -90,6 +90,23 @@ public void noInputAtAll_noFramesEmptyRemainder() { assertThat(r.remainder).isEmpty(); } + @Test + public void yaesuGen3MeterPoll_swrSurvivesCoalescedAndPartialReads() { + // Models the FT-450 / DX10 / Wolf-SDR meter poll: setRead39Meters_ALC() + // then _SWR() sent back-to-back. The transport first delivers the ALC + // reply plus the leading bytes of the SWR reply, then the tail. Draining + // must surface the ALC frame on the first read and the SWR frame on the + // second -- the old parse would have dropped the coalesced SWR reply and + // then poisoned the follow-up read with the retained ';'. + CatLineSplitter.Result first = CatLineSplitter.split("", "RM4100;RM6", ';'); + assertThat(first.frames).containsExactly("RM4100"); + assertThat(first.remainder).isEqualTo("RM6"); + + CatLineSplitter.Result second = CatLineSplitter.split(first.remainder, "020;", ';'); + assertThat(second.frames).containsExactly("RM6020"); + assertThat(second.remainder).isEmpty(); + } + @Test public void carriageReturnTerminator_supportedForSiblingRigs() { // The splitter is terminator-parameterised so the Kenwood/Elecraft '\r' From e662cd725cfe14296b4f227423ce3ba82ab74072 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 18:28:44 -0500 Subject: [PATCH 068/113] Never leave the rig keyed when the USB link dies (#652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Never leave the rig keyed when the USB link dies A POTA session on 2026-07-23 lost the USB bus twelve times (both the CP2102 CAT adapter and the CM108 audio dongle dropping together, ~1-2s apart from re-attach). Four transmissions died mid-audio, and in three of them the rig was left keyed long after the port was back: 08:58:46 keyed, process died 3ms later -> 89s keyed, no unkey ever sent 09:15:46 keyed, USB write failed at :50 -> 97s before the next key event 09:17:23 keyed, USB write failed -> unkey landed immediately (ok) 09:24:01 keyed, USB write failed at :14 -> unkey 30.3s late The port itself came back within ~1.4s each time, so nothing here needed a faster retry at a dead port - it needed the outstanding unkey to be remembered across the outage. 1. A CAT write can no longer kill the process. CommonUsbSerialPort.write() dereferenced mWriteEndpoint, which goes null when the device is yanked mid-flight. That NPE is not an IOException, so it escaped CableSerialPort.sendData's catch and reached the top of the TX pool thread: java.lang.NullPointerException: ... UsbEndpoint.getMaxPacketSize() at CommonUsbSerialPort.write(CommonUsbSerialPort.java:233) at CableConnector.setPttOn(CableConnector.java:175) at Yaesu39Rig.setPTT(Yaesu39Rig.java:108) at MainViewModel$6.beginKeying(MainViewModel.java:801) The process died three milliseconds after TX1; with the transmitter on. write() and read() now fail as IOException on a closed endpoint, which every caller already handles, and sendData catches RuntimeException as a backstop for the rest of the third-party driver stack. 2. The unkey is latched until it is confirmed. New PttSafetyLatch records "we keyed and have not confirmed it back off". Armed in beginKeying before the write, so a throw or a process death still leaves the debt recorded. endKeying only disarms on a write the connector reports as delivered - BaseRigConnector.isLastPttWriteOk(), overridden by CableConnector, distinguishes "PTT-off sent" from "PTT-off attempted at a port that had already gone". retryPendingUnkey() settles the debt wherever a link may be back: on cable reconnect (before retuning - sending frequency and mode to a keyed rig is what makes it click) and at every slot boundary as a backstop. CAT PTT-off is idempotent, so a retry against an already-receiving rig is a no-op. Worst case becomes one slot instead of 97 seconds. 3. ToastMessage no longer crashes the main thread. FATAL EXCEPTION: main, NPE on String.equals at ToastMessage:55 - the 5s expiry pass called debugList.get(i).equals(info) and found a null slot. The comparison now runs from the remembered message, nulls are rejected on the way in, and the expiry walk is synchronized on the same monitor as the writer: it runs on the main thread while messages arrive from the decode, TX and CAT threads, and previously raced the overflow trim. Not addressed: the bus drops themselves. They are not mechanical (operator was not handling the phone) and not RF on key (2 of 12 during TX, against 39% expected from duty cycle alone). Battery telemetry shows the phone at 44-48.6C with the pack sagging 4147mV -> 3725mV across the session, and zero drops in the first 90 minutes while it was cool and full - consistent with the phone browning out its OTG accessory rail. That is a hardware-side fix (external power for the interface); this change is about surviving it. Tests: PttSafetyLatch state machine, ToastMessage expiry including the null element and overflow trim, and closed-endpoint read/write raising IOException rather than NPE. Full suite 2490 pass. Co-Authored-By: Claude Opus 4.8 (1M context) * Address review: harden expire()/read() nulls and make the null-slot test real - ToastMessage.expire(String): no-op on a null message instead of NPEing on info.equals(...); it is package-visible and callable directly. - CommonUsbSerialPort.read(): guard mUsbRequest (nullable after a concurrent close/re-enumeration) on the timeout==0 request path, not just mReadEndpoint. - ToastMessageExpiryTest: reflectively inject a real null slot into debugList so expireDoesNotCrashOnANullElement actually exercises the walk-past-null path, and add a direct expire(null) no-op test. Co-Authored-By: Claude Opus 4.8 (1M context) * Fix post-merge CI: drop dead endpoint guards shadowed by ioEndpointReady The origin/dev merge combined dev's read()/write() (snapshot + ioEndpointReady, which throws "Connection closed" for a null endpoint) with this branch's older explicit `if (mReadEndpoint/mWriteEndpoint == null)` guards that threw "Read/Write endpoint closed". ioEndpointReady now fires first, so those explicit guards were unreachable dead code and ClosedEndpointWriteTest — which asserted the old messages — failed in CI. Remove the redundant explicit guards (dev's ioEndpointReady already turns the yanked-device null-endpoint state into a recoverable IOException) and update ClosedEndpointWriteTest to expect "Connection closed". Keep this branch's unique mUsbRequest guard on the timeout==0 request path, which dev lacks. Full testDebugUnitTest passes. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/MainViewModel.java | 59 +++++++++ .../java/com/k1af/ft8af/PttSafetyLatch.java | 71 +++++++++++ .../ft8af/connector/BaseRigConnector.java | 13 ++ .../k1af/ft8af/connector/CableConnector.java | 19 +++ .../k1af/ft8af/connector/CableSerialPort.java | 13 ++ .../ft8af/serialport/CommonUsbSerialPort.java | 9 +- .../com/k1af/ft8af/PttSafetyLatchTest.java | 94 +++++++++++++++ .../serialport/ClosedEndpointWriteTest.java | 114 ++++++++++++++++++ 8 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/PttSafetyLatch.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/PttSafetyLatchTest.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/serialport/ClosedEndpointWriteTest.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 9fb8874a7..9dfe57d37 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -56,6 +56,7 @@ import com.k1af.ft8af.callsign.CallsignInfo; import com.k1af.ft8af.callsign.OnAfterQueryCallsignLocation; import com.k1af.ft8af.bluetooth.ScoPolicy; +import com.k1af.ft8af.connector.BaseRigConnector; import com.k1af.ft8af.connector.BluetoothRigConnector; import com.k1af.ft8af.connector.CableConnector; import com.k1af.ft8af.connector.CableSerialPort; @@ -237,6 +238,9 @@ public void clearCurrentMessages() { public FT8TransmitSignal ft8TransmitSignal;//object for transmitting signals // Deep-pass decodes that arrived mid-TX, replayed to the sequencer after key-up private final PendingSequencerDecodes pendingSequencerDecodes = new PendingSequencerDecodes(); + // "We keyed the rig and haven't confirmed it back off" — settled by + // retryPendingUnkey() on reconnect and at slot boundaries. + private final PttSafetyLatch pttSafetyLatch = new PttSafetyLatch(); public MeterProtectionController meterProtectionController;//ALC auto-volume + SWR halt public SpectrumListener spectrumListener;//object for drawing the spectrum public boolean markMessage = true;//whether to mark messages toggle @@ -578,6 +582,13 @@ public void beforeListen(long utc) { mutable_Decoded_Counter.postValue(0); publishFt8MessageList(); } + // Backstop for the unkey latch. The reconnect path is the fast + // route (~2s), but it only fires when the USB attach broadcast + // arrives; this bounds a stuck key at one slot even if the link + // recovered without one. + if (!ft8TransmitSignal.isTransmitting()) { + retryPendingUnkey(); + } mutableIsDecoding.postValue(true); } @@ -805,6 +816,10 @@ private void beginKeying() { if (baseRig != null) { //if (GeneralVariables.connectMode != ConnectMode.NETWORK) stopSco(); if (needControlSco()) stopSco(); + // Arm before the write, not after: if setPTT throws or the + // process dies mid-key, we still recorded that the rig may + // be keyed and the next reconnect settles it. + pttSafetyLatch.onKeyed(); baseRig.setPTT(true); } } @@ -817,6 +832,11 @@ private void endKeying() { || GeneralVariables.controlMode == ControlMode.DTR) { if (baseRig != null) { baseRig.setPTT(false); + // Only a confirmed write clears the latch. A PTT-off issued + // at a port that has already gone away leaves the rig keyed, + // so keep the debt and let retryPendingUnkey() settle it when + // the link is back. + pttSafetyLatch.onUnkeyAttempted(lastPttWriteReachedRig()); //if (GeneralVariables.connectMode != ConnectMode.NETWORK) startSco(); if (needControlSco()) startSco(); } @@ -1668,6 +1688,11 @@ public void OnWaveReceived(int bufferLen, float[] buffer) { new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { @Override public void run() { + // Settle any unkey owed from before the link dropped BEFORE + // retuning. If a brown-out killed the port mid-transmission the + // rig is still keyed, and sending frequency/mode to a keyed rig + // is exactly what makes it click and mis-set. + retryPendingUnkey(); setOperationBand();//set carrier frequency } }, 1000); @@ -2015,6 +2040,40 @@ public boolean isRigConnected() { } } + /** Whether the rig's connector believes the last PTT write was delivered. */ + private boolean lastPttWriteReachedRig() { + if (baseRig == null) return false; + BaseRigConnector connector = baseRig.getConnector(); + // No connector at all means nothing was written; treat as undelivered so + // the latch stays armed rather than silently forgiving a lost unkey. + return connector != null && connector.isLastPttWriteOk(); + } + + /** + * Send PTT-off if we still owe the rig one, and clear the debt when it lands. + * + *

Called wherever a CAT link may have just come back: after a cable + * reconnect, and at every slot boundary as a backstop. Safe to call at any + * time — it is a no-op unless an unkey is actually outstanding, and CAT + * PTT-off is idempotent on a rig that is already receiving. + * + *

This is the recovery for the field failure where a USB brown-out killed + * the port mid-transmission and the rig stayed keyed for 97 seconds while the + * port itself was back within two. + * + * @return true if an unkey was owed and has now been sent successfully + */ + public boolean retryPendingUnkey() { + if (!pttSafetyLatch.needsUnkey()) return false; + if (baseRig == null || !baseRig.isConnected()) return false; + fileLog("PTT: unkey still owed after link loss — re-sending PTT-off"); + baseRig.setPTT(false); + boolean ok = lastPttWriteReachedRig(); + pttSafetyLatch.onUnkeyAttempted(ok); + fileLog("PTT: unkey retry " + (ok ? "delivered" : "FAILED, still owed")); + return ok; + } + /** * Whether the connected rig has answered at least one CAT probe since the * current connection came up. Backs the USB Diagnostics "CAT Response" check: diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/PttSafetyLatch.java b/ft8af/app/src/main/java/com/k1af/ft8af/PttSafetyLatch.java new file mode 100644 index 000000000..10780b7fb --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/PttSafetyLatch.java @@ -0,0 +1,71 @@ +package com.k1af.ft8af; + +/** + * Tracks the one piece of rig state we must never lose track of: whether we have + * keyed the transmitter and not yet confirmed it back off. + * + *

The transmit path keys via CAT, plays ~12.6s of audio, then unkeys via CAT. + * If the USB link dies anywhere in between, the unkey write fails and the rig is + * left transmitting. Field log (POTA, 2026-07-23) shows this three times in one + * session: an OTG brown-out re-enumerated the bus mid-transmission and the rig + * stayed keyed for 97s, 30s, and — when the failing write also killed the + * process — 89s. In every case the port came back within ~2s, so the fix is not + * to write harder at a dead port but to remember the debt and settle it + * the moment a link exists again. + * + *

Usage: {@link #onKeyed()} when PTT goes on, {@link #onUnkeyAttempted(boolean)} + * with the write's success after PTT-off, and {@link #needsUnkey()} at every + * opportunity a link might be back (reconnect, slot boundary). The latch stays + * armed until an unkey write is confirmed, so a failed attempt simply leaves it + * armed for the next opportunity. + * + *

Deliberately not "is the rig keyed" — we cannot know that once the link is + * down. It is "we owe this rig an unkey", which is always safe to act on: CAT + * PTT-off is idempotent, so re-sending it to an already-receiving rig is a no-op. + * + *

Thread-safe: keying runs on the TX pool thread, retries on the main thread + * and the audio thread. + */ +public final class PttSafetyLatch { + + private volatile boolean armed; + + /** + * Record that we have keyed the transmitter. From here we owe an unkey until + * one is confirmed, regardless of how transmission ends. + */ + public synchronized void onKeyed() { + armed = true; + } + + /** + * Record the outcome of an unkey attempt. + * + * @param writeSucceeded true when the CAT PTT-off write actually reached the + * rig; false when the port was closed or the write + * threw. Only a confirmed write disarms — a failed one + * leaves the debt outstanding for {@link #needsUnkey()}. + */ + public synchronized void onUnkeyAttempted(boolean writeSucceeded) { + if (writeSucceeded) { + armed = false; + } + } + + /** + * Whether an unkey is still owed. Call at any point a CAT link may have come + * back — reconnect, slot boundary — and send PTT-off if true. + */ + public boolean needsUnkey() { + return armed; + } + + /** + * Drop the debt without sending anything. For teardown paths where the rig + * object itself is going away (disconnect, mode change), so a stale latch + * cannot key-chase a rig we are no longer driving. + */ + public synchronized void reset() { + armed = false; + } +} diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/connector/BaseRigConnector.java b/ft8af/app/src/main/java/com/k1af/ft8af/connector/BaseRigConnector.java index 761e8c55c..d74f7d4c1 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/connector/BaseRigConnector.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/connector/BaseRigConnector.java @@ -71,6 +71,19 @@ public BaseRigConnector(int controlMode) { */ public void setPttOn(byte[] command){}; + /** + * Whether the most recent {@link #setPttOn} write actually reached the rig. + * + *

Lets the transmit path tell "PTT-off sent" from "PTT-off attempted at a + * port that had already gone away" — the difference between a rig that is + * receiving and one left keyed. Connectors that cannot fail this way (network + * rigs, which surface their own errors) keep the optimistic default; the USB + * cable path overrides it. + * + * @return true if the last PTT write is believed delivered + */ + public boolean isLastPttWriteOk(){ return true; } + public void setControlMode(int mode){ controlMode=mode; } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableConnector.java b/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableConnector.java index 7724b51cc..cf91baf94 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableConnector.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableConnector.java @@ -140,6 +140,9 @@ public synchronized void sendData(byte[] data) { } + /** See {@link #isLastPttWriteOk()}. Optimistic until a write actually fails. */ + private volatile boolean lastPttWriteOk = true; + @Override public void setPttOn(boolean on) { //Only handle RTS and DTR @@ -154,6 +157,7 @@ public void setPttOn(boolean on) { for (int attempt = 0; CatReconnectPolicy.shouldRetryPtt(on, ok, attempt); attempt++) { ok = toggleControlLine(mode, on); } + lastPttWriteOk = ok; if (!on && !ok) { Log.e(TAG, "PTT-off write failed after retries; port likely dropped"); } @@ -174,6 +178,21 @@ public void setPttOn(byte[] command) { for (int attempt = 0; CatReconnectPolicy.shouldRetryPtt(false, ok, attempt); attempt++) { ok = cableSerialPort.sendData(command); } + lastPttWriteOk = ok; + if (!ok) { + Log.e(TAG, "CAT PTT write failed after retries; port likely dropped"); + } + } + + /** + * Result of the last {@link #setPttOn} write. False means the port was closed + * or the write threw, so the rig never saw the command — if that command was + * PTT-off, the transmitter is still keyed and the caller owes it a retry once + * the link is back (see {@code PttSafetyLatch}). + */ + @Override + public boolean isLastPttWriteOk() { + return lastPttWriteOk; } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java b/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java index 8af5a1da1..4bfb2ae4c 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java @@ -315,6 +315,19 @@ public boolean sendData(final byte[] src) { e.printStackTrace(); fileLog("serial.send ERROR: " + e.getMessage()); return false; + } catch (RuntimeException e) { + // Backstop. A CAT write must never be fatal: sendData runs on the TX + // pool thread via the PTT path, so an uncaught RuntimeException takes + // the whole process down — and if it happens between TX1 and TX0 the + // rig is left keyed with nothing alive to unkey it (observed in the + // field: process died 3ms after TX1, transmitter keyed for 89s). + // The known case is an NPE from a yanked USB endpoint, now also + // guarded at source in CommonUsbSerialPort; this catch covers the + // rest of the driver stack, which is third-party and NPEs freely + // once the device disappears mid-transfer. + e.printStackTrace(); + fileLog("serial.send ERROR (runtime, port died mid-write): " + e); + return false; } return true; } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/serialport/CommonUsbSerialPort.java b/ft8af/app/src/main/java/com/k1af/ft8af/serialport/CommonUsbSerialPort.java index 88d0f6fa7..f91b7d37d 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/serialport/CommonUsbSerialPort.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/serialport/CommonUsbSerialPort.java @@ -226,8 +226,15 @@ protected int read(final byte[] dest, final int timeout, boolean testConnection) testConnection(); } else { + // mUsbRequest (like mReadEndpoint above) can be null after a concurrent + // close()/re-enumeration; the timeout==0 path dereferences it, so guard + // it here too rather than NPE on the IO thread. + final UsbRequest request = mUsbRequest; + if (request == null) { + throw new IOException("Connection closed"); + } final ByteBuffer buf = ByteBuffer.wrap(dest); - if (!mUsbRequest.queue(buf, dest.length)) { + if (!request.queue(buf, dest.length)) { throw new IOException("Queueing USB request failed"); } final UsbRequest response = connection.requestWait(); diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/PttSafetyLatchTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/PttSafetyLatchTest.java new file mode 100644 index 000000000..e15e38970 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/PttSafetyLatchTest.java @@ -0,0 +1,94 @@ +package com.k1af.ft8af; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Coverage for {@link PttSafetyLatch}, the "we owe this rig an unkey" flag. + * + *

Field failure it exists for (POTA, 2026-07-23): an OTG brown-out + * re-enumerated the USB bus mid-transmission, the CAT PTT-off write went to a + * port that no longer existed, and the rig stayed keyed — 97s in one case, 89s + * in another where the failing write also killed the process. The port itself + * was back within ~2s each time, so the latch's job is to remember the + * outstanding unkey across the outage and settle it on reconnect. + * + *

Plain JUnit: pure state, no Android types. + */ +public class PttSafetyLatchTest { + + @Test + public void startsWithNothingOwed() { + assertThat(new PttSafetyLatch().needsUnkey()).isFalse(); + } + + @Test + public void keyingOwesAnUnkey() { + PttSafetyLatch latch = new PttSafetyLatch(); + latch.onKeyed(); + assertThat(latch.needsUnkey()).isTrue(); + } + + @Test + public void confirmedUnkeySettlesTheDebt() { + PttSafetyLatch latch = new PttSafetyLatch(); + latch.onKeyed(); + latch.onUnkeyAttempted(true); + assertThat(latch.needsUnkey()).isFalse(); + } + + @Test + public void failedUnkeyKeepsTheDebt() { + // The exact field case: PTT-off written to a port that had already gone. + PttSafetyLatch latch = new PttSafetyLatch(); + latch.onKeyed(); + latch.onUnkeyAttempted(false); + assertThat(latch.needsUnkey()).isTrue(); + } + + @Test + public void retryAfterFailureCanSettleIt() { + // Reconnect path: the port came back, so the retry lands. + PttSafetyLatch latch = new PttSafetyLatch(); + latch.onKeyed(); + latch.onUnkeyAttempted(false); + latch.onUnkeyAttempted(false); + assertThat(latch.needsUnkey()).isTrue(); + latch.onUnkeyAttempted(true); + assertThat(latch.needsUnkey()).isFalse(); + } + + @Test + public void unkeyWithoutKeyingIsHarmless() { + // Slot-boundary backstop fires on every cycle, keyed or not. + PttSafetyLatch latch = new PttSafetyLatch(); + latch.onUnkeyAttempted(true); + assertThat(latch.needsUnkey()).isFalse(); + latch.onUnkeyAttempted(false); + assertThat(latch.needsUnkey()).isFalse(); + } + + @Test + public void rekeyingAfterAFailedUnkeyStillOwesOne() { + // Transmission N's unkey was lost, transmission N+1 keys again: still one + // debt outstanding, not zero. + PttSafetyLatch latch = new PttSafetyLatch(); + latch.onKeyed(); + latch.onUnkeyAttempted(false); + latch.onKeyed(); + assertThat(latch.needsUnkey()).isTrue(); + latch.onUnkeyAttempted(true); + assertThat(latch.needsUnkey()).isFalse(); + } + + @Test + public void resetDropsTheDebtWithoutSending() { + // Teardown: the rig object is going away, so a stale latch must not chase + // a rig we no longer drive. + PttSafetyLatch latch = new PttSafetyLatch(); + latch.onKeyed(); + latch.reset(); + assertThat(latch.needsUnkey()).isFalse(); + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/serialport/ClosedEndpointWriteTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/serialport/ClosedEndpointWriteTest.java new file mode 100644 index 000000000..8988fed5f --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/serialport/ClosedEndpointWriteTest.java @@ -0,0 +1,114 @@ +package com.k1af.ft8af.serialport; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import android.hardware.usb.UsbDeviceConnection; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadow.api.Shadow; + +import java.io.IOException; +import java.util.EnumSet; + +/** + * Coverage for the yanked-device guards in {@link CommonUsbSerialPort}. + * + *

Field crash (POTA, 2026-07-23): an OTG brown-out re-enumerated the USB bus + * while the app was keying the rig. {@code mWriteEndpoint} was null, so + * {@code mWriteEndpoint.getMaxPacketSize()} threw NPE — not IOException — which + * escaped {@code CableSerialPort.sendData}'s catch and killed the process 3ms + * after {@code TX1;}. The rig was left transmitting for 89 seconds. + * + *

A dead port must fail as {@link IOException}, which every caller already + * handles. + * + *

Robolectric: {@code UsbDeviceConnection} is an Android type; the test only + * needs a non-null instance so the connection check passes and execution reaches + * the endpoint check. + */ +@RunWith(RobolectricTestRunner.class) +public class ClosedEndpointWriteTest { + + /** Minimal concrete port: the abstract hooks are never reached in these tests. */ + private static class TestPort extends CommonUsbSerialPort { + TestPort() { + super(null, 0); + } + + @Override + public UsbSerialDriver getDriver() { + return null; + } + + @Override + protected void openInt(UsbDeviceConnection connection) { + } + + @Override + protected void closeInt() { + } + + @Override + public EnumSet getSupportedControlLines() { + return EnumSet.noneOf(ControlLine.class); + } + + @Override + public EnumSet getControlLines() { + return EnumSet.noneOf(ControlLine.class); + } + + @Override + public void setParameters(int baudRate, int dataBits, int stopBits, int parity) { + } + } + + private static TestPort portWithConnectionButNoEndpoints() { + TestPort port = new TestPort(); + // Non-null connection, null endpoints: exactly the post-re-enumeration + // state that produced the field NPE. Shadow.newInstanceOf builds the + // framework object without invoking its hidden constructor — the project + // has no mocking library, and nothing here calls into the connection. + port.mConnection = Shadow.newInstanceOf(UsbDeviceConnection.class); + port.mReadEndpoint = null; + port.mWriteEndpoint = null; + return port; + } + + @Test + public void writeWithNoConnectionThrowsIoException() { + TestPort port = new TestPort(); + IOException e = assertThrows(IOException.class, + () -> port.write(new byte[]{'T', 'X', '0', ';'}, 100)); + assertThat(e).hasMessageThat().contains("Connection closed"); + } + + @Test + public void writeWithClosedEndpointThrowsIoExceptionNotNpe() { + // The regression: this used to be a fatal NullPointerException. The + // ioEndpointReady guard treats a null endpoint the same as a missing + // connection, so the message is "Connection closed". + TestPort port = portWithConnectionButNoEndpoints(); + IOException e = assertThrows(IOException.class, + () -> port.write(new byte[]{'T', 'X', '0', ';'}, 100)); + assertThat(e).hasMessageThat().contains("Connection closed"); + } + + @Test + public void readWithClosedEndpointThrowsIoExceptionNotNpe() { + TestPort port = portWithConnectionButNoEndpoints(); + IOException e = assertThrows(IOException.class, + () -> port.read(new byte[64], 100)); + assertThat(e).hasMessageThat().contains("Connection closed"); + } + + @Test + public void emptyWriteOnAClosedEndpointStillFailsCleanly() { + // A zero-length write must not slip past the guard into the loop. + TestPort port = portWithConnectionButNoEndpoints(); + assertThrows(IOException.class, () -> port.write(new byte[0], 100)); + } +} From d5686e79dec05524546a0c4ee313a39c72728a77 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 19:44:40 -0500 Subject: [PATCH 069/113] Add a "Best DX" card to the logbook Stats tab (#673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show operators the furthest station they've worked, measured great-circle from their configured Maidenhead grid, as a highlight card on the Logbook > Stats tab (callsign + distance in the user's preferred unit + grid). Every logged QSO already carries a grid, so this needs no new data — just a reduction over the log. The selection logic is a pure, JVM-only helper (computeBestDx) that takes an injected grid->distance function, so it's unit-tested without Robolectric. It skips records with a blank callsign/grid, an unparseable grid, or a non-positive/NaN distance (the last mirrors MaidenheadGrid's 0-km result for two stations sharing a grid — a floor, never a best DX), and the card only renders once there's a measurable contact (operator grid set + at least one parseable logged grid). Follows the existing extract-pure-helper pattern (gridSquaresWorked / workedGridFields) and reuses MaidenheadGrid.getDist/formatDist for unit-aware display. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../ks3ckc/ft8af/ui/logbook/LogbookScreen.kt | 99 +++++++++++++++++++ .../src/main/res/values/strings_compose.xml | 1 + .../ks3ckc/ft8af/ui/logbook/BestDxTest.kt | 95 ++++++++++++++++++ 3 files changed, 195 insertions(+) create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/BestDxTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt index ac8f1fc59..4141c3915 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt @@ -80,6 +80,7 @@ import com.k1af.ft8af.MainViewModel import com.k1af.ft8af.count.CountDbOpr import com.k1af.ft8af.log.QSLCallsignRecord import com.k1af.ft8af.log.ThirdPartyService +import com.k1af.ft8af.maidenhead.MaidenheadGrid import java.util.Locale import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.Dispatchers @@ -631,6 +632,24 @@ private fun StatsTab(stats: LogbookStats, records: List) { ) } + // Best DX highlight — the furthest station worked, great-circle from the + // operator's grid. Only shown once there's a measurable contact (operator + // grid configured and at least one logged grid that parses). + val myGrid = GeneralVariables.getMyMaidenheadGrid() + val bestDx = remember(records, myGrid) { + computeBestDx(records) { grid -> + if (myGrid.isNullOrEmpty()) { + null + } else { + MaidenheadGrid.getDist(myGrid, grid) + } + } + } + if (bestDx != null) { + SectionHeader(stringResource(R.string.log_section_best_dx)) + BestDxCard(bestDx = bestDx, modifier = Modifier.fillMaxWidth()) + } + // Band donut chart if (stats.bandCounts.isNotEmpty()) { SectionHeader(stringResource(R.string.log_section_band_distribution)) @@ -719,6 +738,52 @@ private fun BigStatCard( } } +// --------------------------------------------------------------------------- +// Best DX card +// --------------------------------------------------------------------------- + +@Composable +private fun BestDxCard(bestDx: BestDx, modifier: Modifier = Modifier) { + // MaidenheadGrid.formatDist already renders in the operator's preferred unit + // (mi/km) with its abbreviated label, so this stays unit-agnostic here. + val distanceText = MaidenheadGrid.formatDist(bestDx.distanceKm) + GlassCard(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = bestDx.callsign, + color = Signal, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + fontFamily = GeistMonoFamily, + ) + if (bestDx.grid.isNotEmpty()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = bestDx.grid, + color = TextMuted, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.04.sp, + ) + } + } + Text( + text = distanceText, + color = Accent, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + ) + } + } +} + // --------------------------------------------------------------------------- // Section header // --------------------------------------------------------------------------- @@ -1066,6 +1131,40 @@ private fun gridSquaresWorked(records: List): Int = if (!grid.isNullOrBlank() && grid.length >= 4) grid.substring(0, 4).uppercase() else null }.distinct().size +// --------------------------------------------------------------------------- +// Helper: furthest station worked ("Best DX") +// --------------------------------------------------------------------------- + +/** The furthest logged station and its great-circle distance from the operator. */ +internal data class BestDx(val callsign: String, val grid: String, val distanceKm: Double) + +/** + * The furthest-worked station across [records], measured great-circle from the + * operator's grid. [distanceKm] maps a remote grid to its distance in km, or + * null when it can't be measured (unparseable grid, or the operator's own grid + * isn't configured). Records with a blank callsign/grid, an un-measurable grid, + * or a non-positive / NaN distance are skipped — the last mirrors + * MaidenheadGrid's 0-km result for two stations sharing a grid, which is a + * distance floor, never a "best DX". Returns null when nothing is measurable. + * + * The distance function is injected so this reducer stays a pure, JVM-only unit + * (the real card passes a MaidenheadGrid-backed lambda). + */ +internal fun computeBestDx( + records: List, + distanceKm: (String) -> Double?, +): BestDx? = + records.asSequence() + .mapNotNull { record -> + val callsign = record.callsign?.trim().orEmpty() + val grid = record.grid?.trim().orEmpty() + if (callsign.isEmpty() || grid.isEmpty()) return@mapNotNull null + val dist = distanceKm(grid) ?: return@mapNotNull null + if (dist.isNaN() || dist <= 0.0) return@mapNotNull null + BestDx(callsign, grid, dist) + } + .maxByOrNull { it.distanceKm } + // =========================================================================== // RECENT TAB // =========================================================================== diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 4e6ba8396..42517743f 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -240,6 +240,7 @@ DXCC Mixed VUCC Grid Squares DXCC Challenge + Best DX Grid Coverage Signal Trend No QSOs yet diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/BestDxTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/BestDxTest.kt new file mode 100644 index 000000000..21ee15a45 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/BestDxTest.kt @@ -0,0 +1,95 @@ +package radio.ks3ckc.ft8af.ui.logbook + +import com.google.common.truth.Truth.assertThat +import com.k1af.ft8af.log.QSLCallsignRecord +import org.junit.Test + +/** + * Unit tests for [computeBestDx] — the pure reducer behind the logbook Stats + * "Best DX" card. The real card measures great-circle distance via + * [com.k1af.ft8af.maidenhead.MaidenheadGrid]; here the distance is injected as a + * fake grid -> km function so the selection/filtering logic is testable on the + * plain JVM (no Robolectric, no operator grid). + */ +class BestDxTest { + + private fun rec(callsign: String?, grid: String?): QSLCallsignRecord = + QSLCallsignRecord().apply { + setCallsign(callsign) + setGrid(grid) + } + + // Maps a (trimmed) grid to a canned distance; unknown grids are "unparseable". + private val fakeDist: (String) -> Double? = { grid -> + when (grid.uppercase()) { + "AA00" -> 100.0 + "BB11" -> 5000.0 + "CC22" -> 18000.0 + else -> null + } + } + + @Test + fun picksTheFurthestStation() { + val best = computeBestDx( + listOf(rec("A1AA", "AA00"), rec("B2BB", "BB11"), rec("C3CC", "CC22")), + fakeDist, + ) + assertThat(best).isNotNull() + assertThat(best!!.callsign).isEqualTo("C3CC") + assertThat(best.grid).isEqualTo("CC22") + assertThat(best.distanceKm).isEqualTo(18000.0) + } + + @Test + fun returnsNullForEmptyLog() { + assertThat(computeBestDx(emptyList(), fakeDist)).isNull() + } + + @Test + fun skipsRecordsWithUnparseableGrid() { + val best = computeBestDx(listOf(rec("A1AA", "ZZ99"), rec("B2BB", "BB11")), fakeDist) + assertThat(best!!.callsign).isEqualTo("B2BB") + } + + @Test + fun skipsBlankCallsignOrGrid() { + val best = computeBestDx( + listOf(rec("", "CC22"), rec("B2BB", ""), rec("D4DD", "BB11")), + fakeDist, + ) + assertThat(best!!.callsign).isEqualTo("D4DD") + assertThat(best.distanceKm).isEqualTo(5000.0) + } + + @Test + fun returnsNullWhenNothingIsMeasurable() { + // Distance fn returns null for everything — e.g. operator grid not set. + assertThat(computeBestDx(listOf(rec("A1AA", "AA00")), { null })).isNull() + } + + @Test + fun skipsZeroAndNaNDistances() { + // A 0 km hit (same grid as the operator) and a NaN must never win over a + // real distance, mirroring MaidenheadGrid's coincident-point behaviour. + val best = computeBestDx( + listOf(rec("SAME", "AA00"), rec("BADGRID", "CC22"), rec("FAR", "BB11")), + { grid -> + when (grid) { + "AA00" -> 0.0 + "CC22" -> Double.NaN + "BB11" -> 5000.0 + else -> null + } + }, + ) + assertThat(best!!.callsign).isEqualTo("FAR") + } + + @Test + fun trimsCallsignAndGrid() { + val best = computeBestDx(listOf(rec(" C3CC ", " CC22 ")), fakeDist) + assertThat(best!!.callsign).isEqualTo("C3CC") + assertThat(best.grid).isEqualTo("CC22") + } +} From ade0eb4bccb0146138afd20f0ded55cdbc0e0d8e Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 21:16:37 -0500 Subject: [PATCH 070/113] Show "Worked before" QSO history when you tap a decoded station (#674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a station in the decode list now surfaces a "Worked before" card right in the QSO sheet: how many times you've logged that callsign, when you last worked them (date · band · mode), and the distinct bands you've already got them on. It renders nothing for a station you've never worked, so first contacts stay uncluttered. This answers the two questions every operator asks before calling — "have I worked them?" and "do I need this band?" — without leaving the decode screen to dig through the logbook. Implementation: - DatabaseOpr.getPriorQsos(call): indexed exact-match read of QSLTable (served by QSLTable_call_IDX), returning lightweight PriorQso rows. - summarizeWorkedBefore()/formatQsoDate(): pure, unit-tested logic that picks the most-recent QSO and the first-worked band order. - WorkedBeforeCard: thin Compose renderer loading the query off the main thread; wired into QsoSheet under the last-heard row. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/database/DatabaseOpr.java | 37 ++++ .../java/com/k1af/ft8af/log/PriorQso.java | 41 ++++ .../radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt | 4 + .../ft8af/ui/decode/WorkedBeforeHistory.kt | 198 ++++++++++++++++++ .../src/main/res/values/strings_compose.xml | 7 + .../ui/decode/WorkedBeforeHistoryTest.kt | 102 +++++++++ 6 files changed, 389 insertions(+) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/log/PriorQso.java create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/WorkedBeforeHistory.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/WorkedBeforeHistoryTest.kt diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index b22e360c6..580bbf86f 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -576,6 +576,43 @@ public java.util.List getAllStatio return out; } + /** + * Every prior logged QSO with {@code callsign}, oldest first, as lightweight + * {@link com.k1af.ft8af.log.PriorQso} rows (date/time/band/mode only). Powers + * the decode sheet's "Worked before" card. Returns an empty list when the + * station has never been worked, on a blank callsign, or on any read error. + * + *

The exact-match on {@code "call"} is served by the {@code QSLTable_call_IDX} + * index, so this stays cheap even on a large log. Decoded callsigns are already + * upper-cased (as are stored ones), so no case-folding is needed — folding here + * would only defeat the index. + */ + public java.util.List getPriorQsos(String callsign) { + java.util.List out = new ArrayList<>(); + if (db == null || callsign == null) { + return out; + } + String c = callsign.trim(); + if (c.isEmpty()) { + return out; + } + try (Cursor cursor = db.rawQuery( + "SELECT qso_date, time_on, band, mode FROM QSLTable " + + "WHERE \"call\" = ? ORDER BY qso_date, time_on", + new String[]{c})) { + while (cursor.moveToNext()) { + out.add(new com.k1af.ft8af.log.PriorQso( + cursor.getString(0), + cursor.getString(1), + cursor.getString(2), + cursor.getString(3))); + } + } catch (Exception e) { + Log.w(TAG, "getPriorQsos failed: " + e.getClass().getSimpleName()); + } + return out; + } + /** Drop every cached signature mapping (e.g. on server/logbook switch). */ public void clearLocationStationCache() { if (db == null) { diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/PriorQso.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/PriorQso.java new file mode 100644 index 000000000..b699eddfd --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/PriorQso.java @@ -0,0 +1,41 @@ +package com.k1af.ft8af.log; + +/** + * A minimal previously-logged QSO record used by the decode sheet's + * "Worked before" history card. Holds only the columns that card needs + * (date/time/band/mode) so it stays cheap to read out of {@code QSLTable} + * in bulk. All fields may be null/empty for a sparse log row. + */ +public class PriorQso { + private final String qsoDate; + private final String timeOn; + private final String band; + private final String mode; + + public PriorQso(String qsoDate, String timeOn, String band, String mode) { + this.qsoDate = qsoDate; + this.timeOn = timeOn; + this.band = band; + this.mode = mode; + } + + /** ADIF {@code qso_date}, typically {@code YYYYMMDD}; may be null/empty. */ + public String getQsoDate() { + return qsoDate; + } + + /** ADIF {@code time_on}, typically {@code HHMMSS} or {@code HHMM}; may be null. */ + public String getTimeOn() { + return timeOn; + } + + /** Band label such as {@code 20m}; may be null/empty. */ + public String getBand() { + return band; + } + + /** Mode such as {@code FT8}; may be null/empty. */ + public String getMode() { + return mode; + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt index 0ea9bfd75..61441b783 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt @@ -152,6 +152,10 @@ private fun QsoSheetContent( Spacer(modifier = Modifier.height(12.dp)) + // -- Worked before: prior-QSO history with this station (renders nothing + // for a never-worked station, so first contacts stay uncluttered) -- + WorkedBeforeCard(callsign = callsign) + // -- Path map: operator grid -> remote grid, with a connecting line. // Renders nothing (and no trailing spacer) when either grid is unknown. QsoPathMap( diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/WorkedBeforeHistory.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/WorkedBeforeHistory.kt new file mode 100644 index 000000000..b1ab76645 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/WorkedBeforeHistory.kt @@ -0,0 +1,198 @@ +package radio.ks3ckc.ft8af.ui.decode + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.k1af.ft8af.R +import com.k1af.ft8af.database.DatabaseOpr +import com.k1af.ft8af.log.PriorQso +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import radio.ks3ckc.ft8af.theme.GeistMonoFamily +import radio.ks3ckc.ft8af.theme.StatusConfirmed +import radio.ks3ckc.ft8af.theme.TextFaint +import radio.ks3ckc.ft8af.theme.TextMuted +import radio.ks3ckc.ft8af.theme.TextPrimary +import radio.ks3ckc.ft8af.ui.components.FT8AFIcons +import radio.ks3ckc.ft8af.ui.components.GlassCard + +/** + * Structured summary of the operator's prior QSOs with one station, ready to + * render in the decode sheet's "Worked before" card. + * + * `lastLine` is a pre-joined "date · band · mode" recap of the most recent + * contact (blank parts skipped); `bands` is the distinct set of bands ever + * worked, in first-worked order, so band-fill hunters can see at a glance what + * they still need. Both are derived by [summarizeWorkedBefore]. + */ +internal data class WorkedBeforeSummary( + val count: Int, + val lastLine: String, + val bands: List, +) + +/** Separator used to join the recap parts and the band list. */ +private const val PART_SEP = " · " // " · " + +/** + * Collapse a list of prior [PriorQso] rows into a [WorkedBeforeSummary], or null + * when there is no prior contact (so the caller renders nothing). + * + * The most recent QSO is chosen by (qso_date, time_on) ascending, independent of + * the input order, so callers don't have to pre-sort. Distinct bands are kept in + * the order they were first worked (input order after the stable sort). + */ +internal fun summarizeWorkedBefore(qsos: List): WorkedBeforeSummary? { + if (qsos.isEmpty()) return null + + val sorted = qsos.sortedWith( + compareBy({ it.qsoDate.orEmpty() }, { it.timeOn.orEmpty() }), + ) + val last = sorted.last() + + val lastLine = listOf( + formatQsoDate(last.qsoDate), + last.band?.trim().orEmpty(), + last.mode?.trim().orEmpty(), + ).filter { it.isNotEmpty() }.joinToString(PART_SEP) + + val bands = sorted + .mapNotNull { it.band?.trim()?.takeIf { b -> b.isNotEmpty() } } + .distinct() + + return WorkedBeforeSummary( + count = qsos.size, + lastLine = lastLine, + bands = bands, + ) +} + +/** + * Format an ADIF `qso_date` (`YYYYMMDD`) as `YYYY-MM-DD`. Anything that isn't + * exactly 8 digits is returned trimmed and unchanged (e.g. already-formatted or + * malformed values), and null becomes "". + */ +internal fun formatQsoDate(raw: String?): String { + val d = raw?.trim().orEmpty() + return if (d.length == 8 && d.all { it.isDigit() }) { + "${d.substring(0, 4)}-${d.substring(4, 6)}-${d.substring(6, 8)}" + } else { + d + } +} + +/** + * "Worked before" card: shows how many times — and most recently when/where — + * the operator has logged the given [callsign], plus the distinct bands worked. + * + * Reads [DatabaseOpr.getPriorQsos] off the main thread and renders nothing until + * the query resolves, or if the station has never been worked (keeps the sheet + * clean for the common first-contact case). The heavy lifting is in the pure + * [summarizeWorkedBefore]; this composable is a thin renderer. + */ +@Composable +internal fun WorkedBeforeCard(callsign: String) { + val context = LocalContext.current + val summary by produceState(initialValue = null, callsign) { + value = if (callsign.isBlank()) { + null + } else { + withContext(Dispatchers.IO) { + val rows = try { + DatabaseOpr.getInstance(context, null).getPriorQsos(callsign.trim()) + } catch (_: Exception) { + emptyList() + } + summarizeWorkedBefore(rows) + } + } + } + + val s = summary ?: return + + // Own the trailing gap so a never-worked station (early return above) leaves + // no empty space between the last-heard row and the path map. + Column(modifier = Modifier.fillMaxWidth()) { + GlassCard( + modifier = Modifier.fillMaxWidth(), + cornerRadius = 12.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + FT8AFIcons.Check(color = StatusConfirmed, size = 16.dp) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.qso_worked_before_title), + color = TextFaint, + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.06.sp, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = pluralStringResource( + R.plurals.qso_worked_before_count, + s.count, + s.count, + ), + color = StatusConfirmed, + fontFamily = GeistMonoFamily, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + ) + } + + if (s.lastLine.isNotEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(R.string.qso_worked_before_last, s.lastLine), + color = TextPrimary, + fontFamily = GeistMonoFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + ) + } + + // Only surface the band list when there's more than one — a single + // band is already implied by the recap line above. + if (s.bands.size > 1) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource( + R.string.qso_worked_before_bands, + s.bands.joinToString(PART_SEP), + ), + color = TextMuted, + fontFamily = GeistMonoFamily, + fontSize = 12.sp, + ) + } + } + } + Spacer(modifier = Modifier.height(12.dp)) + } +} diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 42517743f..8dad64d2d 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -215,6 +215,13 @@ message TX NOW TX NEXT + Worked before + + %1$d QSO + %1$d QSOs + + last %1$s + Bands: %1$s Last heard -- just now diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/WorkedBeforeHistoryTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/WorkedBeforeHistoryTest.kt new file mode 100644 index 000000000..33016e4a1 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/WorkedBeforeHistoryTest.kt @@ -0,0 +1,102 @@ +package radio.ks3ckc.ft8af.ui.decode + +import com.google.common.truth.Truth.assertThat +import com.k1af.ft8af.log.PriorQso +import org.junit.Test + +/** + * Unit tests for the pure "Worked before" summariser logic. [PriorQso] is a + * plain POJO and [summarizeWorkedBefore]/[formatQsoDate] touch no Android types, + * so no Robolectric runner is needed. + */ +class WorkedBeforeHistoryTest { + + private fun qso( + date: String, + time: String = "120000", + band: String? = "20m", + mode: String? = "FT8", + ) = PriorQso(date, time, band, mode) + + // ---- summarizeWorkedBefore ---- + + @Test + fun `empty log yields null so nothing renders`() { + assertThat(summarizeWorkedBefore(emptyList())).isNull() + } + + @Test + fun `single QSO reports count 1 and a formatted recap line`() { + val s = summarizeWorkedBefore(listOf(qso("20250612", "143000", "20m", "FT8")))!! + assertThat(s.count).isEqualTo(1) + assertThat(s.lastLine).isEqualTo("2025-06-12 · 20m · FT8") + assertThat(s.bands).containsExactly("20m") + } + + @Test + fun `most recent QSO is chosen regardless of input order`() { + val s = summarizeWorkedBefore( + listOf( + qso("20240101", "090000", "40m", "FT8"), + qso("20260724", "010000", "15m", "FT4"), + qso("20250612", "143000", "20m", "FT8"), + ), + )!! + assertThat(s.count).isEqualTo(3) + // 2026-07-24 is the newest, so its band/mode drive the recap line. + assertThat(s.lastLine).isEqualTo("2026-07-24 · 15m · FT4") + } + + @Test + fun `same-day QSOs break the tie on time_on`() { + val s = summarizeWorkedBefore( + listOf( + qso("20250612", "235900", "20m", "FT8"), + qso("20250612", "000100", "40m", "FT8"), + ), + )!! + assertThat(s.lastLine).isEqualTo("2025-06-12 · 20m · FT8") + } + + @Test + fun `distinct bands are kept in first-worked order`() { + val s = summarizeWorkedBefore( + listOf( + qso("20240101", "090000", "40m"), + qso("20250101", "090000", "20m"), + qso("20250601", "090000", "40m"), // repeat 40m — deduped + qso("20260101", "090000", "15m"), + ), + )!! + assertThat(s.bands).containsExactly("40m", "20m", "15m").inOrder() + } + + @Test + fun `blank band and mode parts are skipped in the recap line`() { + val s = summarizeWorkedBefore(listOf(qso("20250612", "120000", " ", null)))!! + assertThat(s.lastLine).isEqualTo("2025-06-12") + assertThat(s.bands).isEmpty() + } + + @Test + fun `unparseable date is passed through so the recap is never empty`() { + val s = summarizeWorkedBefore(listOf(qso("2025-06-12", "120000", "20m", "FT8")))!! + assertThat(s.lastLine).isEqualTo("2025-06-12 · 20m · FT8") + } + + // ---- formatQsoDate ---- + + @Test + fun `formatQsoDate turns YYYYMMDD into YYYY-MM-DD`() { + assertThat(formatQsoDate("20250612")).isEqualTo("2025-06-12") + } + + @Test + fun `formatQsoDate leaves non-8-digit values untouched and trims`() { + assertThat(formatQsoDate(" 2025 ")).isEqualTo("2025") + assertThat(formatQsoDate("2025-06-12")).isEqualTo("2025-06-12") + assertThat(formatQsoDate("abcdefgh")).isEqualTo("abcdefgh") + assertThat(formatQsoDate(null)).isEqualTo("") + assertThat(formatQsoDate("")).isEqualTo("") + } +} From ec6d21c62255f553b90d0009d995d6f2304ebfd8 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 21:16:40 -0500 Subject: [PATCH 071/113] Add a callsign watchlist that alerts the instant a wanted station decodes (#676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DXers and friend-hunters want to know the moment a specific station is on the air — a rare DXpedition (3Y0J), a needed prefix, or a friend — without having to stare at the decode list. FT8AF already alerts on new DXCC / new state / CQ-reply / QSO-complete, but had no way to watch a chosen call. Add a "Callsign watchlist" to Settings → Decode Filters → Needed-DX Alerts: enter comma-separated calls or prefixes (e.g. "3Y0, W1AW, TX7"). The moment any decoded station whose callsign starts with a watchlist entry is heard — on any band and whether it's calling CQ or mid-QSO — a high-priority sound + vibrate + notification fires (deduped once per station per session, reusing the existing dx_alerts channel). Prefix matching means a DXpedition prefix catches every variant (3Y0J, 3Y0J/MM) and a full call still matches its portable suffix (W1AW/P). Reuses the existing DxAlertNotifier / AlertDecisions plumbing and the blocklist's token parsing; the watchlist is persisted via the "watchCallsigns" config key. Pure decision + matcher logic (AlertDecisions.shouldAlertWatch / watchDedupKey, GeneralVariables.checkIsWatchedCallsign) is unit-tested. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/GeneralVariables.java | 40 ++++++++++ .../com/k1af/ft8af/alert/AlertDecisions.java | 21 ++++++ .../com/k1af/ft8af/alert/DxAlertNotifier.java | 21 +++++- .../com/k1af/ft8af/database/DatabaseOpr.java | 3 + .../ft8af/ui/settings/DecodeFilterSettings.kt | 26 +++++++ .../src/main/res/values/strings_compose.xml | 5 ++ .../GeneralVariablesWatchCallsignTest.java | 75 +++++++++++++++++++ .../k1af/ft8af/alert/AlertDecisionsTest.java | 35 +++++++++ 8 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/GeneralVariablesWatchCallsignTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index ca08879f0..133a18282 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -220,6 +220,15 @@ public void setMainContext(Context context) { private static final java.util.LinkedHashSet blockedExactCallsigns = new java.util.LinkedHashSet<>(); private static final java.util.LinkedHashSet blockedKeywords = new java.util.LinkedHashSet<>(); + // Callsign watchlist (Settings → Needed-DX Alerts → Watchlist). When non-empty, + // a decoded message from a matching station fires a high-priority alert (sound + + // vibrate + notification) via DxAlertNotifier — the "tell me the instant this + // station is on the air" hunt tool for a rare DXpedition, a needed prefix, or a + // friend. Entries match by callsign PREFIX (so "3Y0" catches 3Y0J and 3Y0J/MM, + // and a full call like "W1AW" also matches "W1AW/P"), mirroring the excluded- + // callsign prefix semantics. Unlike the needed-DX alerts it is not CQ-gated. + private static final java.util.LinkedHashSet watchCallsigns = new java.util.LinkedHashSet<>(); + /** * Split a user-entered list on comma / space / pipe / Chinese comma and * collect the non-empty, upper-cased tokens into {@code target}. @@ -297,6 +306,37 @@ public static synchronized String getBlockedKeywords() { return joinBlockTokens(blockedKeywords); } + /** Replace the callsign watchlist from a user-entered comma/space/pipe list. */ + public static synchronized void addWatchCallsigns(String callsigns) { + parseBlockTokens(callsigns, watchCallsigns); + } + + /** The watchlist in canonical comma-separated form (for persistence + display). */ + public static synchronized String getWatchCallsigns() { + return joinBlockTokens(watchCallsigns); + } + + /** Whether the user has any watchlist entries (gates the watchlist alert). */ + public static synchronized boolean hasWatchCallsigns() { + return !watchCallsigns.isEmpty(); + } + + /** + * Whether {@code callsign} matches the watchlist by PREFIX (case-insensitive): + * "3Y0" matches 3Y0J / 3Y0J/MM, "W1AW" matches W1AW / W1AW/P. Anchored at the + * start, so "W1AW" does not match "KW1AW". Empty list matches nothing. + */ + public static synchronized boolean checkIsWatchedCallsign(String callsign) { + if (callsign == null || watchCallsigns.isEmpty()) return false; + String up = callsign.toUpperCase(); + for (String prefix : watchCallsigns) { + if (up.startsWith(prefix)) { + return true; + } + } + return false; + } + /** * Check whether a callsign is blocked by any match mode: exact whole-call, * prefix, or keyword substring (against the callsign itself). diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/alert/AlertDecisions.java b/ft8af/app/src/main/java/com/k1af/ft8af/alert/AlertDecisions.java index ed00a123a..6c32918bb 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/alert/AlertDecisions.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/alert/AlertDecisions.java @@ -42,6 +42,27 @@ public static String qsoCompleteDedupKey(String toCallsign, String endTime) { return "QSO:" + norm(toCallsign) + "|" + norm(endTime); } + /** + * Watchlist alert — fire when a decoded station matches the user's callsign + * watchlist (a rare DXpedition prefix, a needed call, a friend). Unlike the + * needed-DX alerts this is not CQ-gated: the point is to know the instant the + * station is on the air, whatever it's transmitting. + * + * @param hasWatchlist whether the user has any watchlist entries (the feature is + * active purely by having a non-empty list — no separate toggle) + * @param matched whether the decoded sender matches a watchlist entry + * (resolved by the caller via {@code checkIsWatchedCallsign}) + * @param blocked whether the message is filtered by the user's block list + */ + public static boolean shouldAlertWatch(boolean hasWatchlist, boolean matched, boolean blocked) { + return hasWatchlist && matched && !blocked; + } + + /** Dedup key for a watchlist alert: alert once per session per watched station. */ + public static String watchDedupKey(String fromCallsign) { + return "WATCH:" + norm(fromCallsign); + } + private static String norm(String s) { return s == null ? "" : s.trim().toUpperCase(); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java b/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java index ca144f99a..5a37b9f97 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java @@ -30,7 +30,9 @@ *

    *
  • {@code dx_alerts} — a station calling CQ that is a NEW (unworked) entity in a * user-enabled category: a new DXCC ({@link GeneralVariables#alertNewDxcc}) or US - * state ({@link GeneralVariables#alertNewState}). Driven from + * state ({@link GeneralVariables#alertNewState}); and any decode from a station on + * the user's callsign watchlist ({@link GeneralVariables#checkIsWatchedCallsign}, + * not CQ-gated). Driven from * {@code MainViewModel.GetQTHRunnable.run()} once the {@code from*} flags are set.
  • *
  • {@code qso_alerts} — someone calling YOU * ({@link GeneralVariables#alertOnCqReply}, also driven from {@code processDecodes}) @@ -65,7 +67,7 @@ private void createChannels() { appContext.getSystemService(Context.NOTIFICATION_SERVICE); if (nm == null) return; createHighChannel(nm, CHANNEL_ID, "Needed-DX alerts", - "New DXCC / US state stations calling CQ"); + "New DXCC / US state stations calling CQ, and watchlist callsigns"); createHighChannel(nm, QSO_CHANNEL_ID, "QSO & CQ alerts", "Someone calling you, and completed-QSO alerts"); } @@ -95,7 +97,7 @@ private void createHighChannel(NotificationManager nm, String id, String name, S public void processDecodes(List messages) { if (appContext == null || messages == null) return; if (!GeneralVariables.alertNewDxcc && !GeneralVariables.alertNewState - && !GeneralVariables.alertOnCqReply) { + && !GeneralVariables.alertOnCqReply && !GeneralVariables.hasWatchCallsigns()) { return; } @@ -103,6 +105,19 @@ public void processDecodes(List messages) { if (msg == null) continue; if (GeneralVariables.checkIsBlockedMessage(msg)) continue; + // Watchlist: any decode from a station on the user's watchlist — a rare + // DXpedition, a needed prefix, a friend. NOT CQ-gated (checked before the + // CQ-only continue below) so an in-QSO watched station still alerts the + // instant it's on the air. Blocked messages already `continue`d above. + boolean watched = GeneralVariables.checkIsWatchedCallsign(msg.getCallsignFrom()); + if (AlertDecisions.shouldAlertWatch( + GeneralVariables.hasWatchCallsigns(), watched, false)) { + fire(AlertDecisions.watchDedupKey(msg.getCallsignFrom()), + CHANNEL_ID, + appContext.getString(R.string.alert_watch_title, msg.getCallsignFrom()), + defaultBody(msg), msg.getCallsignFrom(), msg.band); + } + // CQ reply: any decoded message addressed to my callsign. The batch is already // own-TX-echo filtered upstream, so a message to my call is another station // calling me. Checked before the CQ-only gate below since a reply isn't a CQ. diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 580bbf86f..060fe4bf1 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2928,6 +2928,9 @@ protected Void doInBackground(Void... voids) { if (name.equalsIgnoreCase("blockedKeywords")) {//Blocklist: keyword substrings GeneralVariables.addBlockedKeywords(result); } + if (name.equalsIgnoreCase("watchCallsigns")) {//Watchlist: alert on these call(prefix)es + GeneralVariables.addWatchCallsigns(result); + } if (name.equalsIgnoreCase("filterShowOnlyCQ")) {//Decode filter: CQ only GeneralVariables.filterShowOnlyCQ = result.equals("1"); } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt index c7f1788ef..3d7e59231 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt @@ -52,6 +52,7 @@ fun DecodeFilterSettings( var alertNewState by remember { mutableStateOf(GeneralVariables.alertNewState) } var alertOnCqReply by remember { mutableStateOf(GeneralVariables.alertOnCqReply) } var alertOnQsoComplete by remember { mutableStateOf(GeneralVariables.alertOnQsoComplete) } + var watchCallsigns by remember { mutableStateOf(GeneralVariables.getWatchCallsigns()) } // Continent codes (stored on the message) and their display names, parallel lists. val continentCodes = listOf("NA", "SA", "EU", "AF", "AS", "OC", "AN") @@ -88,6 +89,7 @@ fun DecodeFilterSettings( var showWorkedModePicker by remember { mutableStateOf(false) } var showWorkedScopePicker by remember { mutableStateOf(false) } var showWorkedListDialog by remember { mutableStateOf(false) } + var showWatchDialog by remember { mutableStateOf(false) } // -- Blocklist: exact whole-call dialog -- if (showBlockExactDialog) { @@ -203,6 +205,22 @@ fun DecodeFilterSettings( ) } + // -- Needed-DX alerts: callsign watchlist editor -- + if (showWatchDialog) { + TextListDialog( + title = stringResource(R.string.settings_watch_dialog_title), + description = stringResource(R.string.settings_watch_dialog_desc), + initialValue = watchCallsigns, + onDismiss = { showWatchDialog = false }, + onSave = { text -> + GeneralVariables.addWatchCallsigns(text) + watchCallsigns = GeneralVariables.getWatchCallsigns() + mainViewModel.databaseOpr.writeConfig("watchCallsigns", watchCallsigns, null) + showWatchDialog = false + }, + ) + } + SettingsDetailScaffold( title = stringResource(R.string.settings_cat_decode_filters), onBack = onBack, @@ -484,6 +502,14 @@ fun DecodeFilterSettings( SettingsSection(title = stringResource(R.string.settings_section_needed_dx_alerts)) { GlassCard(modifier = Modifier.fillMaxWidth()) { Column { + SettingsRow( + label = stringResource(R.string.settings_alert_watchlist), + description = stringResource(R.string.settings_alert_watchlist_desc), + value = watchCallsigns.ifBlank { stringResource(R.string.common_none) }, + showChevron = true, + onClick = { showWatchDialog = true }, + ) + SectionDivider() SettingsRow( label = stringResource(R.string.settings_alert_new_dxcc), description = stringResource(R.string.settings_alert_new_dxcc_desc), diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 8dad64d2d..003c32207 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -625,6 +625,11 @@ Sound + vibrate + notify when a QSO is logged Calling you: %1$s QSO complete + Callsign watchlist + Sound + vibrate + notify the moment a watched callsign or prefix is decoded, on any band + Callsign watchlist + Comma-separated calls or prefixes (e.g. 3Y0, W1AW, TX7). An alert fires the instant a match is decoded — matched by prefix, so a DXpedition prefix catches every variant. + Watchlist: %1$s Background receiving Keeps FT8 decoding running while the app is in the background or the screen is off FT8AF is receiving diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/GeneralVariablesWatchCallsignTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/GeneralVariablesWatchCallsignTest.java new file mode 100644 index 000000000..810c24df1 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/GeneralVariablesWatchCallsignTest.java @@ -0,0 +1,75 @@ +package com.k1af.ft8af; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** + * Pins the callsign watchlist matcher used by the "Watchlist" alert + * ({@link com.k1af.ft8af.alert.DxAlertNotifier}). Entries match by callsign + * PREFIX so a DXpedition prefix (e.g. "3Y0") catches every variant it decodes + * and a full call ("W1AW") still matches a portable suffix ("W1AW/P"). + */ +@RunWith(RobolectricTestRunner.class) +public class GeneralVariablesWatchCallsignTest { + + @Before + public void setUp() { + GeneralVariables.addWatchCallsigns(""); + } + + @After + public void tearDown() { + GeneralVariables.addWatchCallsigns(""); + } + + @Test + public void emptyWatchlist_matchesNothing_andReportsEmpty() { + assertThat(GeneralVariables.hasWatchCallsigns()).isFalse(); + assertThat(GeneralVariables.checkIsWatchedCallsign("3Y0J")).isFalse(); + } + + @Test + public void addAndGet_roundTripsCanonicalCsv() { + GeneralVariables.addWatchCallsigns("3y0, w1aw"); + assertThat(GeneralVariables.hasWatchCallsigns()).isTrue(); + assertThat(GeneralVariables.getWatchCallsigns()).isEqualTo("3Y0,W1AW"); + } + + @Test + public void prefixMatch_catchesDxpeditionVariants() { + GeneralVariables.addWatchCallsigns("3Y0"); + assertThat(GeneralVariables.checkIsWatchedCallsign("3Y0J")).isTrue(); + assertThat(GeneralVariables.checkIsWatchedCallsign("3Y0J/MM")).isTrue(); + // Same prefix, different station still counts — that's the point of a prefix watch. + assertThat(GeneralVariables.checkIsWatchedCallsign("3Y0K")).isTrue(); + // A different entity must not match. + assertThat(GeneralVariables.checkIsWatchedCallsign("W1AW")).isFalse(); + } + + @Test + public void fullCall_stillMatchesPortableSuffix() { + GeneralVariables.addWatchCallsigns("W1AW"); + assertThat(GeneralVariables.checkIsWatchedCallsign("W1AW")).isTrue(); + assertThat(GeneralVariables.checkIsWatchedCallsign("W1AW/P")).isTrue(); + // Must not match a different call that merely shares a leading substring in + // the middle — the match is anchored at the start. + assertThat(GeneralVariables.checkIsWatchedCallsign("KW1AW")).isFalse(); + } + + @Test + public void matcher_isCaseInsensitive() { + GeneralVariables.addWatchCallsigns("tx7"); + assertThat(GeneralVariables.checkIsWatchedCallsign("TX7L")).isTrue(); + } + + @Test + public void nullCallsign_isSafe() { + GeneralVariables.addWatchCallsigns("W1AW"); + assertThat(GeneralVariables.checkIsWatchedCallsign(null)).isFalse(); + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/alert/AlertDecisionsTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/alert/AlertDecisionsTest.java index 6e855182d..f19005dc4 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/alert/AlertDecisionsTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/alert/AlertDecisionsTest.java @@ -53,4 +53,39 @@ public void qsoCompleteDedupKey_uniquePerCallAndTime() { assertThat(k).isEqualTo("QSO:W5XYZ|20231114-123456"); assertThat(k).isNotEqualTo(AlertDecisions.qsoCompleteDedupKey("W5XYZ", "20231114-123457")); } + + @Test + public void watch_firesOnlyWhenListNonEmptyMatchedAndNotBlocked() { + assertThat(AlertDecisions.shouldAlertWatch(true, true, false)).isTrue(); + } + + @Test + public void watch_suppressedWhenListEmpty() { + assertThat(AlertDecisions.shouldAlertWatch(false, true, false)).isFalse(); + } + + @Test + public void watch_suppressedWhenNotMatched() { + assertThat(AlertDecisions.shouldAlertWatch(true, false, false)).isFalse(); + } + + @Test + public void watch_suppressedWhenBlocked() { + assertThat(AlertDecisions.shouldAlertWatch(true, true, true)).isFalse(); + } + + @Test + public void watchDedupKey_isStableAndCaseInsensitive() { + String a = AlertDecisions.watchDedupKey(" 3y0j "); + String b = AlertDecisions.watchDedupKey("3Y0J"); + assertThat(a).isEqualTo(b); + assertThat(a).isEqualTo("WATCH:3Y0J"); + } + + @Test + public void watchDedupKey_differsPerCallAndHandlesNull() { + assertThat(AlertDecisions.watchDedupKey("W1AW")) + .isNotEqualTo(AlertDecisions.watchDedupKey("W2AW")); + assertThat(AlertDecisions.watchDedupKey(null)).isEqualTo("WATCH:"); + } } From ddf0a6a2874c5b85383c6b0a1b88027b758e6914 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 21:16:46 -0500 Subject: [PATCH 072/113] Add a day/night gray-line overlay to the world map (#675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serious HF operators chase the gray line — the moving band of sunrise/ sunset where propagation is briefly enhanced — but FT8AF's map had no way to see where it is. This adds a real day/night terminator overlay to the standard (equirectangular) map: the night side is shaded with a translucent wash and the terminator itself is drawn as a soft amber line, creeping across the map in real time (recomputed each minute). A "GRAY" toggle sits next to the PSK overlay toggle (standard view only, since it's the equirectangular map that draws it) and its state persists via the grayLineEnabled config key. On by default so users notice it. The solar geometry lives in a new pure Terminator.kt (sub-solar point via the NOAA low-precision algorithm, solar elevation, terminator latitude, and the night polygon), kept free of Android/Compose types and covered by TerminatorTest; the Composable and DrawScope code stay thin wrappers. A Robolectric test covers the new toggle control. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/GeneralVariables.java | 1 + .../com/k1af/ft8af/database/DatabaseOpr.java | 3 + .../radio/ks3ckc/ft8af/ui/map/MapScreen.kt | 111 +++++++++++++ .../radio/ks3ckc/ft8af/ui/map/Terminator.kt | 151 ++++++++++++++++++ .../src/main/res/values/strings_compose.xml | 2 + .../ft8af/ui/map/MapOverlayToggleTest.kt | 15 ++ .../ks3ckc/ft8af/ui/map/TerminatorTest.kt | 138 ++++++++++++++++ 7 files changed, 421 insertions(+) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/Terminator.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/TerminatorTest.kt diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 133a18282..54646e933 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -491,6 +491,7 @@ public static Context getMainContext() { public static String qrzXmlUsername = ""; //QRZ XML API username (for callsign lookups) public static String qrzXmlPassword = ""; //QRZ XML API password public static boolean pskOverlayEnabled = false; //PSK Reporter map overlay (issue #33) + public static boolean grayLineEnabled = true; //Day/night terminator (gray line) map overlay — on by default public static boolean synFrequency = false;//Same-frequency transmit // Hold TX freq: don't move the TX offset to a station you answer (WSJT-X // "Hold Tx Freq"). Enabled by default (issue #498) to keep the TX frequency diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 060fe4bf1..646d7cebd 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -3089,6 +3089,9 @@ protected Void doInBackground(Void... voids) { if (name.equalsIgnoreCase("pskOverlayEnabled")) { GeneralVariables.pskOverlayEnabled = result.equals("1"); } + if (name.equalsIgnoreCase("grayLineEnabled")) { + GeneralVariables.grayLineEnabled = result.equals("1"); + } if (name.equalsIgnoreCase("swrSwitch")) { GeneralVariables.swr_switch_on = result.equals("1"); diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt index 840432071..b1fcc3058 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt @@ -53,6 +53,8 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -117,6 +119,12 @@ private const val MAP_MIN_ZOOM = 1f private const val MAP_MAX_ZOOM = 8f private const val MAP_ZOOM_STEP = 2f +// Gray-line overlay colors. The night side is a translucent navy wash so land +// and markers stay legible beneath it; the terminator itself is a soft amber +// stroke — the "gray line" HF operators chase for enhanced propagation. +private val GrayLineNightFill = Color(0x4A0B1220) +private val GrayLineTerminator = Color(0xB3F5B44A) + // Persists the PSK filter across config changes / process death. band == "" means all. private val PskFilterSaver = listSaver( save = { listOf(it.timeWindowSec, it.band?.name ?: "", it.mode.name, it.direction.name) }, @@ -223,6 +231,24 @@ fun MapScreen(mainViewModel: MainViewModel) { var pskFilter by rememberSaveable(stateSaver = PskFilterSaver) { mutableStateOf(PskFilter()) } var filterSheetOpen by rememberSaveable { mutableStateOf(false) } + // Gray-line (day/night terminator) overlay. On by default — it's a passive, + // translucent shade that HF operators use to spot enhanced-propagation paths. + // `nowMillis` ticks once a minute so the terminator creeps across the map in + // real time; the Sun moves ~0.25°/min, far slower than the tick. + var grayLineEnabled by rememberSaveable { mutableStateOf(GeneralVariables.grayLineEnabled) } + var nowMillis by remember { mutableStateOf(System.currentTimeMillis()) } + LaunchedEffect(grayLineEnabled) { + while (grayLineEnabled) { + nowMillis = System.currentTimeMillis() + delay(60_000L) + } + } + // The night region as a lat/lon ring, recomputed only when the minute ticks + // (not every animation frame). The canvas just projects + fills it. + val nightRing = remember(grayLineEnabled, nowMillis) { + if (grayLineEnabled) nightPolygonLatLon(subsolarPoint(nowMillis)) else null + } + // Zoom + pan (issue #51). Scale is clamped to [1, MAX_ZOOM]; pan is clamped so the // scaled map can't be dragged entirely off-screen. Both reset whenever the projection // changes — pan offsets in screen pixels don't translate meaningfully between @@ -412,6 +438,7 @@ fun MapScreen(mainViewModel: MainViewModel) { stations = stations, pskSpots = pskSpots, connectionLines = connectionLines, + nightRing = nightRing, selectedCallsign = selectedCallsign, onStationSelected = { selectedCallsign = it }, scale = mapScale, @@ -489,6 +516,22 @@ fun MapScreen(mainViewModel: MainViewModel) { FilterPill(active = filterSheetOpen, onClick = { filterSheetOpen = !filterSheetOpen }) Spacer(modifier = Modifier.width(6.dp)) } + // Gray-line toggle — only the equirectangular map draws the terminator. + if (viewMode == MapViewMode.STANDARD) { + GrayLineToggle( + enabled = grayLineEnabled, + onToggle = { newVal -> + grayLineEnabled = newVal + GeneralVariables.grayLineEnabled = newVal + mainViewModel.databaseOpr.writeConfig( + "grayLineEnabled", + if (newVal) "1" else "0", + null, + ) + }, + ) + Spacer(modifier = Modifier.width(6.dp)) + } PskOverlayToggle( enabled = pskOverlayEnabled, onToggle = { newVal -> @@ -771,6 +814,28 @@ internal fun PskOverlayToggle(enabled: Boolean, onToggle: (Boolean) -> Unit) { } } +@Composable +internal fun GrayLineToggle(enabled: Boolean, onToggle: (Boolean) -> Unit) { + val cd = stringResource(R.string.map_overlay_grayline_cd) + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(if (enabled) GrayLineTerminator else BgSurface3) + .clickable { onToggle(!enabled) } + .semantics { contentDescription = cd } + .padding(horizontal = 10.dp, vertical = 5.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.map_overlay_grayline), + color = if (enabled) BgApp else TextMuted, + fontFamily = GeistMonoFamily, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + @Composable private fun TogglePill( label: String, @@ -1059,6 +1124,7 @@ private fun StandardMapCanvas( stations: List, pskSpots: List, connectionLines: List, + nightRing: FloatArray?, selectedCallsign: String?, onStationSelected: (String?) -> Unit, scale: Float, @@ -1094,6 +1160,10 @@ private fun StandardMapCanvas( // Lat/lon grid (over land so it stays visible across continents) drawEquirectGrid(vp) + // Gray-line (day/night terminator) — translucent night wash + amber + // terminator stroke, drawn over land/grid but under the markers. + nightRing?.let { ring -> drawGrayLine(ring, vp) } + // Operator marker (at projected lat/lon, scaled + panned with map) val opPos = vp.projectLatLon(opLat, opLon) val opX = opPos.x @@ -1368,6 +1438,47 @@ private fun DrawScope.drawWorldLand(rings: List, vp: EquirectViewpor drawPath(path, color = Color(0x9094A3B8), style = Stroke(width = 0.75f)) // outline } +/** + * Draw the gray-line overlay on the equirectangular map: fill the night side + * with a translucent wash and stroke the terminator itself. [ring] is the flat + * `[lon, lat, …]` night polygon from [nightPolygonLatLon] — its last two + * vertices close the ring along the dark pole, so the terminator stroke uses + * everything but those two. + */ +private fun DrawScope.drawGrayLine(ring: FloatArray, vp: EquirectViewport) { + if (ring.size < 8) return + val cx = vp.canvasW / 2f + val cy = vp.canvasH / 2f + val sxUnit = vp.worldPxW / 2f + val syUnit = vp.worldPxH / 2f + fun px(lon: Float) = cx + (lon / 180f) * sxUnit + vp.panX + fun py(lat: Float) = cy + (-lat / 90f) * syUnit + vp.panY + + // Filled night region (the whole closed ring). + val fill = Path() + var i = 0 + while (i + 1 < ring.size) { + val x = px(ring[i]) + val y = py(ring[i + 1]) + if (i == 0) fill.moveTo(x, y) else fill.lineTo(x, y) + i += 2 + } + fill.close() + drawPath(fill, color = GrayLineNightFill) + + // Terminator stroke — the curve only, dropping the two pole-closing vertices. + val curveFloats = ring.size - 4 + val stroke = Path() + var j = 0 + while (j < curveFloats) { + val x = px(ring[j]) + val y = py(ring[j + 1]) + if (j == 0) stroke.moveTo(x, y) else stroke.lineTo(x, y) + j += 2 + } + drawPath(stroke, color = GrayLineTerminator, style = Stroke(width = 1.5f)) +} + // --------------------------------------------------------------------------- // Azimuthal land renderer + drawing helpers // --------------------------------------------------------------------------- diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/Terminator.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/Terminator.kt new file mode 100644 index 000000000..98089195e --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/Terminator.kt @@ -0,0 +1,151 @@ +package radio.ks3ckc.ft8af.ui.map + +import java.util.Calendar +import java.util.TimeZone +import kotlin.math.abs +import kotlin.math.asin +import kotlin.math.atan +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.tan + +/** + * Day/night terminator ("gray line") geometry for the world map. + * + * The gray line — the moving band of sunrise/sunset sweeping across the Earth — + * is where HF propagation is briefly enhanced, so operators deliberately hunt + * along it. This file computes where the Sun is and where the terminator falls + * as pure functions (no Android/Compose types), so the map Composable stays a + * thin wrapper and the math is unit-tested directly. + * + * All angles are degrees unless noted; longitudes are east-positive in + * [-180, 180]; times are UTC epoch milliseconds. + */ + +/** The point on Earth directly beneath the Sun (Sun at the zenith). */ +internal data class SubsolarPoint(val lat: Double, val lon: Double) + +/** + * Sub-solar point for a UTC instant, via the NOAA low-precision solar-position + * algorithm. Accurate to a few tenths of a degree — far finer than the map's + * grid, and plenty for a gray-line overlay that only has to look right. + */ +internal fun subsolarPoint(utcMillis: Long): SubsolarPoint { + val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + cal.timeInMillis = utcMillis + val dayOfYear = cal.get(Calendar.DAY_OF_YEAR) + val hoursUtc = cal.get(Calendar.HOUR_OF_DAY) + + cal.get(Calendar.MINUTE) / 60.0 + + cal.get(Calendar.SECOND) / 3600.0 + + // Fractional-year angle γ (radians): NOAA uses (day - 1) + (hour - 12) / 24. + val gamma = 2.0 * Math.PI / 365.0 * (dayOfYear - 1 + (hoursUtc - 12.0) / 24.0) + + // Equation of time (minutes) — the Sun's longitude wobble vs. clock time. + val eqTime = 229.18 * ( + 0.000075 + + 0.001868 * cos(gamma) - + 0.032077 * sin(gamma) - + 0.014615 * cos(2 * gamma) - + 0.040849 * sin(2 * gamma) + ) + // Solar declination (radians) = sub-solar latitude. + val declRad = 0.006918 - + 0.399912 * cos(gamma) + 0.070257 * sin(gamma) - + 0.006758 * cos(2 * gamma) + 0.000907 * sin(2 * gamma) - + 0.002697 * cos(3 * gamma) + 0.00148 * sin(3 * gamma) + + val subsolarLat = Math.toDegrees(declRad) + // The Sun is overhead at true solar noon; each UTC hour past noon moves that + // meridian 15° west. The equation of time nudges it off clock noon. + val subsolarLon = normalizeLon(-15.0 * (hoursUtc + eqTime / 60.0 - 12.0)) + return SubsolarPoint(subsolarLat, subsolarLon) +} + +/** Wrap a longitude into [-180, 180]. */ +internal fun normalizeLon(lon: Double): Double { + var l = lon % 360.0 + if (l > 180.0) l -= 360.0 + if (l < -180.0) l += 360.0 + return l +} + +/** + * Solar elevation (degrees) at [lat]/[lon] given the [subsolar] point: the Sun's + * angle above the horizon. Positive = daytime, negative = night, ≈0 = on the + * terminator (the gray line itself). + */ +internal fun solarElevationDeg(lat: Double, lon: Double, subsolar: SubsolarPoint): Double { + val phi = Math.toRadians(lat) + val decl = Math.toRadians(subsolar.lat) + val hourAngle = Math.toRadians(lon - subsolar.lon) + val sinElev = sin(phi) * sin(decl) + cos(phi) * cos(decl) * cos(hourAngle) + return Math.toDegrees(asin(sinElev.coerceIn(-1.0, 1.0))) +} + +/** Whether [lat]/[lon] is on the night side (Sun below the horizon). */ +internal fun isNight(lat: Double, lon: Double, subsolar: SubsolarPoint): Boolean = + solarElevationDeg(lat, lon, subsolar) < 0.0 + +/** + * The terminator latitude at a given [lon] — the latitude where the Sun sits on + * the horizon. From sin φ·sin δ + cos φ·cos δ·cos H = 0 → tan φ = −cos H / tan δ. + * + * Near the equinoxes δ → 0 makes the true terminator two vertical meridians with + * no single latitude per longitude; |δ| is floored to [MIN_DECL_DEG] so the + * curve stays steep-but-finite (a good visual stand-in for the ~2 days a year + * this matters) instead of blowing up. + */ +internal fun terminatorLatitude(lon: Double, subsolar: SubsolarPoint): Double { + val declDeg = subsolar.lat.let { + when { + it >= 0.0 && it < MIN_DECL_DEG -> MIN_DECL_DEG + it < 0.0 && it > -MIN_DECL_DEG -> -MIN_DECL_DEG + else -> it + } + } + val decl = Math.toRadians(declDeg) + val hourAngle = Math.toRadians(lon - subsolar.lon) + return Math.toDegrees(atan(-cos(hourAngle) / tan(decl))) +} + +/** Floor on |declination| used by [terminatorLatitude] to survive the equinox. */ +private const val MIN_DECL_DEG = 0.3 + +/** + * The terminator as a flat `[lon0, lat0, lon1, lat1, …]` polyline sampled every + * [stepDeg] degrees of longitude from −180 to +180. Suitable for drawing the + * gray-line stroke; see [nightPolygonLatLon] for the filled night region. + */ +internal fun terminatorCurveLatLon(subsolar: SubsolarPoint, stepDeg: Double = 3.0): FloatArray { + val step = if (stepDeg <= 0.0) 3.0 else stepDeg + val out = ArrayList() + var lon = -180.0 + while (lon <= 180.0 + 1e-9) { + out.add(lon.toFloat()) + out.add(terminatorLatitude(lon, subsolar).toFloat()) + lon += step + } + return out.toFloatArray() +} + +/** + * The night region as a flat `[lon0, lat0, …]` closed ring: the terminator curve + * plus two vertices along whichever pole is in darkness (the south pole when the + * Sun is north of the equator, the north pole otherwise). Fill this ring to + * shade the dark side of the map. + */ +internal fun nightPolygonLatLon(subsolar: SubsolarPoint, stepDeg: Double = 3.0): FloatArray { + val curve = terminatorCurveLatLon(subsolar, stepDeg) + // Sun north of the equator ⇒ the south pole is dark; close the ring there. + val poleLat = if (subsolar.lat >= 0.0) -90f else 90f + val out = FloatArray(curve.size + 4) + System.arraycopy(curve, 0, out, 0, curve.size) + // Curve ends at lon +180; drop to the dark pole, run back to lon −180, and + // let the fill auto-close to the curve's first vertex. + out[curve.size] = 180f + out[curve.size + 1] = poleLat + out[curve.size + 2] = -180f + out[curve.size + 3] = poleLat + return out +} diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 003c32207..d9632c8cd 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -370,6 +370,8 @@ STD AZ PSK + GRAY + Gray line (day/night terminator) overlay Filter PSK filters Done diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/MapOverlayToggleTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/MapOverlayToggleTest.kt index 93eb242f7..4c5519003 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/MapOverlayToggleTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/MapOverlayToggleTest.kt @@ -43,6 +43,21 @@ class MapOverlayToggleTest { assertThat(toggledTo).isTrue() } + @Test + fun grayLineToggle_clickEmitsToggledValue() { + val label = context.getString(R.string.map_overlay_grayline) + var toggledTo: Boolean? = null + composeRule.setContent { + GrayLineToggle(enabled = true, onToggle = { toggledTo = it }) + } + + composeRule.onNodeWithText(label).assertIsDisplayed() + composeRule.onNodeWithText(label).performClick() + + // Starts enabled, so a tap turns it off. + assertThat(toggledTo).isFalse() + } + @Test fun mapViewToggle_selectingAzimuthalEmitsThatMode() { val azimuthalLabel = context.getString(R.string.map_mode_azimuthal) diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/TerminatorTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/TerminatorTest.kt new file mode 100644 index 000000000..c1c0ccc43 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/TerminatorTest.kt @@ -0,0 +1,138 @@ +package radio.ks3ckc.ft8af.ui.map + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.util.Calendar +import java.util.GregorianCalendar +import java.util.TimeZone +import kotlin.math.abs + +/** + * Pure unit tests for the day/night terminator (gray-line) math. No Android or + * Compose runtime is touched, so these run as plain JVM tests. + */ +class TerminatorTest { + + private fun utc(year: Int, month: Int, day: Int, hour: Int, minute: Int = 0): Long = + GregorianCalendar(TimeZone.getTimeZone("UTC")).apply { + clear() + set(year, month, day, hour, minute, 0) + }.timeInMillis + + // ----------------------------------------------------------------------- + // Sub-solar point + // ----------------------------------------------------------------------- + + @Test + fun subsolar_summerSolstice_isNearMaxNorthernDeclination() { + // ~June 21: the Sun is overhead near the Tropic of Cancer (+23.44°). + val p = subsolarPoint(utc(2024, Calendar.JUNE, 21, 12)) + assertThat(p.lat).isWithin(0.6).of(23.44) + } + + @Test + fun subsolar_winterSolstice_isNearMaxSouthernDeclination() { + // ~Dec 21: the Sun is overhead near the Tropic of Capricorn (−23.44°). + val p = subsolarPoint(utc(2024, Calendar.DECEMBER, 21, 12)) + assertThat(p.lat).isWithin(0.6).of(-23.44) + } + + @Test + fun subsolar_equinox_isNearEquator() { + val p = subsolarPoint(utc(2024, Calendar.MARCH, 20, 12)) + assertThat(abs(p.lat)).isLessThan(1.0) + } + + @Test + fun subsolar_longitudeTracksUtcHour() { + // At 12:00 UTC the Sun is near the prime meridian (± the equation of time). + assertThat(abs(subsolarPoint(utc(2024, Calendar.MARCH, 20, 12)).lon)).isLessThan(2.0) + // Each hour before/after noon shifts the sub-solar meridian 15° east/west. + assertThat(subsolarPoint(utc(2024, Calendar.MARCH, 20, 6)).lon).isWithin(2.0).of(90.0) + assertThat(subsolarPoint(utc(2024, Calendar.MARCH, 20, 18)).lon).isWithin(2.0).of(-90.0) + } + + @Test + fun normalizeLon_wrapsIntoRange() { + assertThat(normalizeLon(190.0)).isWithin(1e-9).of(-170.0) + assertThat(normalizeLon(-190.0)).isWithin(1e-9).of(170.0) + assertThat(normalizeLon(45.0)).isWithin(1e-9).of(45.0) + assertThat(normalizeLon(540.0)).isWithin(1e-9).of(180.0) + } + + // ----------------------------------------------------------------------- + // Solar elevation / night test + // ----------------------------------------------------------------------- + + @Test + fun solarElevation_isMaxAtSubsolarPoint_andMinAtAntipode() { + val sub = SubsolarPoint(lat = 10.0, lon = 40.0) + assertThat(solarElevationDeg(10.0, 40.0, sub)).isWithin(1e-6).of(90.0) + assertThat(solarElevationDeg(-10.0, normalizeLon(40.0 + 180.0), sub)).isWithin(1e-6).of(-90.0) + } + + @Test + fun isNight_subsolarIsDay_antipodeIsNight() { + val sub = SubsolarPoint(lat = 0.0, lon = 0.0) + assertThat(isNight(0.0, 0.0, sub)).isFalse() + assertThat(isNight(0.0, 180.0, sub)).isTrue() + } + + // ----------------------------------------------------------------------- + // Terminator latitude + // ----------------------------------------------------------------------- + + @Test + fun terminatorLatitude_liesOnTheZeroElevationBoundary() { + // Well away from the equinox so |δ| isn't floored: the returned latitude + // is exactly where the Sun grazes the horizon (elevation ≈ 0). + val sub = subsolarPoint(utc(2024, Calendar.JUNE, 21, 9)) + for (lon in -180..180 step 30) { + val lat = terminatorLatitude(lon.toDouble(), sub) + assertThat(solarElevationDeg(lat, lon.toDouble(), sub)).isWithin(1e-6).of(0.0) + } + } + + @Test + fun terminatorLatitude_survivesEquinoxWithoutBlowingUp() { + // δ ≈ 0: without the floor this divides by ~0. Assert it stays finite and + // in-range instead of producing NaN/∞. + val sub = SubsolarPoint(lat = 0.0, lon = 0.0) + val lat = terminatorLatitude(45.0, sub) + assertThat(lat.isFinite()).isTrue() + assertThat(abs(lat)).isAtMost(90.0) + } + + // ----------------------------------------------------------------------- + // Night polygon + // ----------------------------------------------------------------------- + + @Test + fun terminatorCurve_spansFullLongitudeRange() { + val curve = terminatorCurveLatLon(SubsolarPoint(20.0, 0.0), stepDeg = 3.0) + assertThat(curve.size % 2).isEqualTo(0) + assertThat(curve.first()).isWithin(1e-3f).of(-180f) // first lon + assertThat(curve[curve.size - 2]).isWithin(1e-3f).of(180f) // last lon + } + + @Test + fun nightPolygon_closesToTheDarkPole() { + // Sun in the north ⇒ the south pole is dark: the ring closes at lat −90. + val north = nightPolygonLatLon(SubsolarPoint(20.0, 0.0), stepDeg = 3.0) + assertThat(north[north.size - 1]).isWithin(1e-3f).of(-90f) + assertThat(north[north.size - 3]).isWithin(1e-3f).of(-90f) + + // Sun in the south ⇒ the north pole is dark: the ring closes at lat +90. + val south = nightPolygonLatLon(SubsolarPoint(-20.0, 0.0), stepDeg = 3.0) + assertThat(south[south.size - 1]).isWithin(1e-3f).of(90f) + assertThat(south[south.size - 3]).isWithin(1e-3f).of(90f) + } + + @Test + fun nightPolygon_hasFourMoreCoordsThanTheCurve() { + val sub = SubsolarPoint(15.0, -30.0) + val curve = terminatorCurveLatLon(sub, stepDeg = 5.0) + val ring = nightPolygonLatLon(sub, stepDeg = 5.0) + assertThat(ring.size).isEqualTo(curve.size + 4) + } +} From 593720c86adbc0666a59560f768551ce786610f5 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 21:22:45 -0500 Subject: [PATCH 073/113] Add callsign/grid/band search to the Logbook Recent tab (#677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Recent tab listed every logged QSO with no way to search, so answering the everyday question "have I worked this station before, and on what band?" meant scrolling the whole log. Add a search box above the recent list that filters as you type. The match is a case-insensitive substring over the fields an operator actually searches a log by — callsign (primary), grid, band, DXCC entity, and the stored location — so a partial call ("K1A"), a grid ("FN42"), a band ("40M"), or a country all narrow the list. A clear (✕) button resets it, and a distinct "no matching QSOs" state keeps the search box in place so the query can be refined. The match logic is extracted into a pure, unit-tested filterQsoRecords helper; the search bar is a thin Compose wrapper over it. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../ks3ckc/ft8af/ui/logbook/LogbookScreen.kt | 171 ++++++++++++++++-- .../src/main/res/values/strings_compose.xml | 4 + .../ft8af/ui/logbook/LogbookSearchTest.kt | 122 +++++++++++++ 3 files changed, 277 insertions(+), 20 deletions(-) create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookSearchTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt index 4141c3915..5844352c8 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -57,7 +58,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -1185,6 +1189,35 @@ internal fun sortQsosByDateTimeDesc( records: List, ): List = records.sortedByDescending { qsoSortKey(it) } +/** + * Filter the recent-QSO list by a free-text [query] typed into the logbook search + * box. A blank/whitespace query returns the list unchanged (no filtering). + * + * Otherwise the trimmed query is matched case-insensitively as a *substring* + * against the fields an operator actually searches a log by — callsign (the + * primary target), grid, band, DXCC entity, and the stored "where" location — so + * typing a partial call ("K1A"), a grid ("FN42"), a band ("40M"), or a country + * narrows the log to the matching contacts. Matching a substring (rather than a + * prefix) means "PA" finds both "PA3XYZ" and "W1PA". Extracted as a pure function + * so the search behavior is unit-tested without Compose. + */ +internal fun filterQsoRecords( + records: List, + query: String, +): List { + val q = query.trim().uppercase() + if (q.isEmpty()) return records + return records.filter { record -> + sequenceOf( + record.callsign, + record.grid, + record.band, + record.dxccStr, + record.where, + ).any { it != null && it.uppercase().contains(q) } + } +} + internal fun qsoSortKey(record: QSLCallsignRecord): String { val date = (record.lastTime ?: "").padEnd(8, '0') return date + normalizeTimeOn(record.timeOn) @@ -1235,30 +1268,128 @@ private fun RecentTab( return } - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 18.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - items( - items = sortQsosByDateTimeDesc(records), - // Include id so an edit that changes other fields still maps to a stable key, - // and so two grouped rows with otherwise identical display fields don't collide. - key = { "${it.id}_${it.callsign}_${it.lastTime}_${it.band}" }, - ) { record -> - QsoRow( - record = record, - onEdit = { onEdit(record) }, - onDelete = { onDelete(record) }, - ) - } + // Free-text search over the log (callsign / grid / band / DXCC). Kept in a + // rememberSaveable so the query survives recomposition and rotation; it resets + // when the operator leaves the Logbook, which matches the expectation that + // search is a transient "find this contact" action, not a persisted filter. + var query by rememberSaveable { mutableStateOf("") } + val filtered = remember(records, query) { filterQsoRecords(records, query) } + + Column(modifier = Modifier.fillMaxSize()) { + LogSearchBar( + query = query, + onQueryChange = { query = it }, + modifier = Modifier + .padding(horizontal = 18.dp) + .padding(bottom = 6.dp), + ) - // Bottom spacer for safe area - item { Spacer(modifier = Modifier.height(16.dp)) } + if (filtered.isEmpty()) { + // Records exist but none match — keep the search bar above so the + // operator can refine or clear the query. + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + EmptyStateWaves(size = 140.dp) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.log_search_no_results_title), + color = TextMuted, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.log_search_no_results_body, query.trim()), + color = TextFaint, + fontSize = 12.sp, + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 18.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items( + items = sortQsosByDateTimeDesc(filtered), + // Include id so an edit that changes other fields still maps to a stable key, + // and so two grouped rows with otherwise identical display fields don't collide. + key = { "${it.id}_${it.callsign}_${it.lastTime}_${it.band}" }, + ) { record -> + QsoRow( + record = record, + onEdit = { onEdit(record) }, + onDelete = { onDelete(record) }, + ) + } + + // Bottom spacer for safe area + item { Spacer(modifier = Modifier.height(16.dp)) } + } + } } } +/** + * Search field for the Logbook Recent tab. A thin Compose wrapper around an + * [OutlinedTextField]; all match logic lives in the pure [filterQsoRecords]. + */ +@Composable +private fun LogSearchBar( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier.fillMaxWidth(), + singleLine = true, + placeholder = { + Text( + text = stringResource(R.string.log_search_hint), + color = TextFaint, + fontSize = 13.sp, + fontFamily = GeistMonoFamily, + ) + }, + leadingIcon = { + radio.ks3ckc.ft8af.ui.components.FT8AFIcons.Search(color = TextMuted, size = 18.dp) + }, + trailingIcon = { + if (query.isNotEmpty()) { + val clearLabel = stringResource(R.string.log_search_clear) + IconButton(onClick = { onQueryChange("") }) { + radio.ks3ckc.ft8af.ui.components.FT8AFIcons.Close( + modifier = Modifier.semantics { contentDescription = clearLabel }, + color = TextMuted, + size = 18.dp, + ) + } + } + }, + textStyle = TextStyle( + color = TextPrimary, + fontSize = 14.sp, + fontFamily = GeistMonoFamily, + ), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + cursorColor = Accent, + focusedBorderColor = Accent, + unfocusedBorderColor = Border, + ), + ) +} + // --------------------------------------------------------------------------- // QSO row // --------------------------------------------------------------------------- diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index d9632c8cd..1e8590c16 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -252,6 +252,10 @@ Signal Trend No QSOs yet No QSOs recorded yet + Search callsign, grid, band… + Clear search + No matching QSOs + Nothing in your log matches “%1$s” %1$s, USA QSO actions Edit diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookSearchTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookSearchTest.kt new file mode 100644 index 000000000..8e8fb2198 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookSearchTest.kt @@ -0,0 +1,122 @@ +package radio.ks3ckc.ft8af.ui.logbook + +import com.k1af.ft8af.log.QSLCallsignRecord +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [filterQsoRecords], the pure search extracted from the Logbook + * Recent tab so an operator can find a contact by callsign, grid, band, or DXCC. + * + * QSLCallsignRecord is a plain POJO, so these run on the bare JVM with no + * Robolectric. + */ +class LogbookSearchTest { + + private fun record( + callsign: String, + grid: String = "", + band: String = "", + dxcc: String = "", + where: String? = null, + ): QSLCallsignRecord { + val r = QSLCallsignRecord() + r.setCallsign(callsign) + r.setGrid(grid) + r.setBand(band) + r.dxccStr = dxcc + r.where = where + return r + } + + private fun calls(records: List): List = + records.map { it.callsign } + + private val log = listOf( + record("K1ABC", grid = "FN42", band = "20M", dxcc = "United States"), + record("PA3XYZ", grid = "JO22", band = "40M", dxcc = "Netherlands"), + record("VK2DEF", grid = "QF56", band = "20M", dxcc = "Australia"), + record("JA1QRP", grid = "PM95", band = "15M", dxcc = "Japan"), + ) + + @Test + fun blankQueryReturnsEverythingUnchanged() { + assertThat(filterQsoRecords(log, "")).isEqualTo(log) + assertThat(filterQsoRecords(log, " ")).isEqualTo(log) + } + + @Test + fun matchesByCallsignPrefix() { + val result = filterQsoRecords(log, "K1") + assertThat(calls(result)).containsExactly("K1ABC") + } + + @Test + fun matchesCallsignSubstringNotJustPrefix() { + // "XYZ" appears at the end of a callsign — a substring match (not a prefix + // match) must still find it so the search is forgiving. + val busier = listOf( + record("PA3XYZ", grid = "JO22", band = "40M"), + record("W1ABC", grid = "FN31", band = "20M"), + record("K9XYZ", grid = "EN52", band = "15M"), + ) + val result = filterQsoRecords(busier, "XYZ") + assertThat(calls(result)).containsExactly("PA3XYZ", "K9XYZ").inOrder() + } + + @Test + fun isCaseInsensitive() { + val result = filterQsoRecords(log, "ja1qrp") + assertThat(calls(result)).containsExactly("JA1QRP") + } + + @Test + fun matchesByGrid() { + val result = filterQsoRecords(log, "JO22") + assertThat(calls(result)).containsExactly("PA3XYZ") + } + + @Test + fun matchesByBand() { + val result = filterQsoRecords(log, "20M") + assertThat(calls(result)).containsExactly("K1ABC", "VK2DEF").inOrder() + } + + @Test + fun matchesByDxccEntity() { + val result = filterQsoRecords(log, "japan") + assertThat(calls(result)).containsExactly("JA1QRP") + } + + @Test + fun matchesByWhereLocation() { + val withWhere = listOf(record("EA8ABC", where = "Canary Islands")) + val result = filterQsoRecords(withWhere, "canary") + assertThat(calls(result)).containsExactly("EA8ABC") + } + + @Test + fun trimsSurroundingWhitespaceInQuery() { + val result = filterQsoRecords(log, " K1ABC ") + assertThat(calls(result)).containsExactly("K1ABC") + } + + @Test + fun noMatchReturnsEmpty() { + assertThat(filterQsoRecords(log, "ZZ9ZZZ")).isEmpty() + } + + @Test + fun emptyLogReturnsEmpty() { + assertThat(filterQsoRecords(emptyList(), "K1ABC")).isEmpty() + } + + @Test + fun toleratesNullOptionalFields() { + // where is null and dxcc empty on many rows; the match must not NPE and + // should still find the callsign. + val sparse = listOf(record("N0CALL")) + val result = filterQsoRecords(sparse, "N0") + assertThat(calls(result)).containsExactly("N0CALL") + } +} From 3bb6b96be1d5c875e4c1add2f781d3130e149c1b Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 22:05:32 -0500 Subject: [PATCH 074/113] Fix Elecraft CAT: coalesced SWR/freq replies dropped, retained ';' poisons next parse (#671) The Elecraft onReceiveData() reassembled its ';'-terminated CAT stream one command at a time: it parsed only the FIRST command in a read and pushed the rest back into the buffer WITH its retained terminator. The transport delivers bytes in arbitrary chunks, so a single read routinely coalesces two back-to-back replies (successive SWR meter polls during TX, or a freq reply followed by a meter reply). Consequences: * the second coalesced reply was dropped, then wiped by the next poll's clearBufferData() -- so the higher (worse) of two coalesced SWR readings could be lost and the high-SWR alert miss a spike; and * the retained ';' poisoned the next parse -- the stale ID was read as the command ID and the real command landed inside the data field and was discarded. Fix: drain EVERY complete command per read via the shared CatLineSplitter (the same reassembler the FT-891 fix, #665, introduced for these text-framed rigs), carrying only the trailing unterminated fragment into the next read. The per-command logic is extracted verbatim into processCommand() so onReceiveData() stays a thin drain loop; a latent double getFrequency() call is collapsed. Tests: two CatLineSplitterTest cases pinning the Elecraft coalesced-meter and freq+meter poison scenarios. Full :app:testDebugUnitTest green. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/rigs/ElecraftRig.java | 69 ++++++++++--------- .../k1af/ft8af/rigs/CatLineSplitterTest.java | 22 ++++++ 2 files changed, 60 insertions(+), 31 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/ElecraftRig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/ElecraftRig.java index f7d6a2b6d..26ef1c6a2 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/ElecraftRig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/ElecraftRig.java @@ -122,42 +122,49 @@ public void setFreqToRig() { public void onReceiveData(byte[] data) { String s = new String(data); - if (!s.contains(";")) { - buffer.append(s); - if (buffer.length() > 1000) clearBufferData(); - // return;//data reception not yet complete. - } else { - if (s.indexOf(";") > 0) {//received end-of-data, and ';' is not the first character - buffer.append(s.substring(0, s.indexOf(";"))); - } - - //begin parsing data - ElecraftCommand elecraftCommand = ElecraftCommand.getCommand(buffer.toString()); - clearBufferData();//clear buffer - //put remaining data into buffer - buffer.append(s.substring(s.indexOf(";") + 1)); + // Drain every complete ';'-terminated command in this read. The transport + // delivers bytes in arbitrary chunks, so a single read routinely coalesces + // two back-to-back replies (e.g. successive SWR meter polls during TX, or a + // freq reply followed by a meter reply). The old parser handled only the + // first command and re-buffered the rest WITH its retained terminator, so + // the second reply was dropped (then wiped by the next poll's + // clearBufferData()) and the retained ';' poisoned the next parse (the + // stale ID was read as the command and the real command landed in the data + // field). Only the trailing, unterminated fragment is carried over. + CatLineSplitter.Result result = CatLineSplitter.split(buffer.toString(), s, ';'); + clearBufferData(); + buffer.append(result.remainder); + // A runaway unterminated buffer must not grow without bound. + if (buffer.length() > 1000) clearBufferData(); + + for (String command : result.frames) { + processCommand(command); + } + } - if (elecraftCommand == null) { - return; + /** + * Parse and act on one complete CAT command (terminator already stripped). + * Extracted so {@link #onReceiveData} stays a thin drain loop. + */ + private void processCommand(String command) { + ElecraftCommand elecraftCommand = ElecraftCommand.getCommand(command); + if (elecraftCommand == null) { + return; + } + if (elecraftCommand.getCommandID().equalsIgnoreCase("FA") + || elecraftCommand.getCommandID().equalsIgnoreCase("FB")) { + long tempFreq = ElecraftCommand.getFrequency(elecraftCommand); + if (tempFreq != 0) {//if tempFreq==0, frequency is invalid + setFreq(tempFreq); } - if (elecraftCommand.getCommandID().equalsIgnoreCase("FA") - || elecraftCommand.getCommandID().equalsIgnoreCase("FB")) { - long tempFreq = ElecraftCommand.getFrequency(elecraftCommand); - if (tempFreq != 0) {//if tempFreq==0, frequency is invalid - setFreq(ElecraftCommand.getFrequency(elecraftCommand)); - } - } else if (elecraftCommand.getCommandID().equalsIgnoreCase("SW")) {//METER - if (ElecraftCommand.isSWRMeter(elecraftCommand)) { - swr = ElecraftCommand.getSWRMeter(elecraftCommand); - } - - showAlert(); - notifyMeterData(-1, Math.min(swr * 4, 255)); + } else if (elecraftCommand.getCommandID().equalsIgnoreCase("SW")) {//METER + if (ElecraftCommand.isSWRMeter(elecraftCommand)) { + swr = ElecraftCommand.getSWRMeter(elecraftCommand); } - + showAlert(); + notifyMeterData(-1, Math.min(swr * 4, 255)); } - } @Override diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java index b06ecff44..5d49c1158 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java @@ -107,6 +107,28 @@ public void yaesuGen3MeterPoll_swrSurvivesCoalescedAndPartialReads() { assertThat(second.remainder).isEmpty(); } + @Test + public void elecraftCoalescedSwrMeterPolls_bothReadingsSurvive() { + // During TX the Elecraft SWR meter is polled repeatedly and two "SW" + // replies routinely coalesce into one read. The old per-rig reassembly + // kept only the first and dropped the second (higher, i.e. worse) reading, + // so the SWR alert could miss a spike. Both must now be delivered. + CatLineSplitter.Result r = CatLineSplitter.split("", "SW030;SW045;", ';'); + assertThat(r.frames).containsExactly("SW030", "SW045").inOrder(); + assertThat(r.remainder).isEmpty(); + } + + @Test + public void elecraftFreqThenMeter_retainedTerminatorCannotPoisonNextParse() { + // A freq reply coalesced with a meter reply: the old code parsed FA, then + // re-buffered "SW045;" WITH its ';'. If a further read arrived before the + // buffer was cleared, "SW045;" + next command concatenated into a single + // poisoned parse. The splitter yields both as clean, separate frames. + CatLineSplitter.Result r = CatLineSplitter.split("", "FA00014074000;SW045;", ';'); + assertThat(r.frames).containsExactly("FA00014074000", "SW045").inOrder(); + assertThat(r.remainder).isEmpty(); + } + @Test public void carriageReturnTerminator_supportedForSiblingRigs() { // The splitter is terminator-parameterised so the Kenwood/Elecraft '\r' From c13edfb27b0f4e315b5fe058358bfe9bedf7327c Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 22:18:11 -0500 Subject: [PATCH 075/113] Fix dropped Needed-DX alerts: overlapping decode passes raced a shared QTH-lookup Runnable (#678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainViewModel.afterDecode() is entered concurrently (slot N's late/deep pass and slot N+1's early pass, #398 — the same reason ft8Messages and decodeCycleState are synchronized in that method). Yet the per-cycle QTH lookup reused a single GetQTHRunnable instance: each call assigned getQTHRunnable.messages = messages on the shared object and re-submitted it to a cached thread pool. When two passes overlap, the second field write clobbers the first, so both pool workers process the same list. The other pass's messages — typically the deep/late pass that decoded the DX the fast pass missed — never get their country/grid flags resolved (getMessagesLocation) and never reach dxAlertNotifier.processDecodes(), so their Needed-DX alerts are silently dropped. It is also a plain data race on a non-volatile field written by decode threads and read by pool threads. Fix: bind the payload to a fresh task per dispatch (new GetQTHRunnable(this, messages)) with final fields, removing the shared mutable instance entirely. The TX SendWaveDataRunnable had the identical shared-mutable-pooled-Runnable shape (re-entrant transmit/tune could clobber baseRig/message on a worker still playing the prior waveform), so it gets the same per-dispatch treatment — matching the already-merged IcomAudioUdp fix. Adds MainViewModelPooledTaskTest covering per-dispatch binding plus a deterministic model of the dropped-pass hazard. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/MainViewModel.java | 63 +++++++--- .../ft8af/MainViewModelPooledTaskTest.java | 113 ++++++++++++++++++ 2 files changed, 157 insertions(+), 19 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelPooledTaskTest.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 9dfe57d37..0a41a1376 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -209,8 +209,6 @@ public void clearCurrentMessages() { private final ExecutorService getQTHThreadPool = Executors.newCachedThreadPool(); private final ExecutorService sendWaveDataThreadPool = Executors.newCachedThreadPool(); - private final GetQTHRunnable getQTHRunnable = new GetQTHRunnable(this); - private final SendWaveDataRunnable sendWaveDataRunnable = new SendWaveDataRunnable(); //variables for displaying shared log generation progress @@ -728,12 +726,19 @@ public void afterDecode(long utc, float time_sec, int sequential mutableIsDecoding.postValue(decodingMarkerAfterPass(decoded.size(), messages.size())); - getQTHRunnable.messages = messages; + // A fresh task per dispatch, bound to *this* pass's messages. slot N's + // late/deep pass and slot N+1's early pass enter afterDecode concurrently + // (#398, same reason ft8Messages/decodeCycleState are synchronized above): + // a single shared Runnable whose `messages` field is overwritten per call + // and re-submitted to the pool loses one pass's list — that pass's QTH + // lookup (country/grid flags) and Needed-DX alerts are silently dropped, + // and two pool workers race on the one non-volatile field. // Guard against the ViewModel-teardown race: a late deep-decode // pass can deliver here after onCleared() shut the pool down, // and a raw execute() on a terminated pool throws // RejectedExecutionException on this decode thread (crash). - SafeExecutor.tryExecute(getQTHThreadPool, getQTHRunnable);//query location via thread pool + SafeExecutor.tryExecute(getQTHThreadPool, + new GetQTHRunnable(MainViewModel.this, messages));//query location via thread pool //this variable also notifies message list changes mutable_Decoded_Counter.postValue( @@ -873,10 +878,11 @@ public void onTransmitByWifi(Ft8Message msg) { if (GeneralVariables.connectMode == ConnectMode.NETWORK) { if (baseRig != null) { if (baseRig.isConnected()) { - sendWaveDataRunnable.baseRig = baseRig; - sendWaveDataRunnable.message = msg; - //send network data packets via thread pool - SafeExecutor.tryExecute(sendWaveDataThreadPool, sendWaveDataRunnable); + //send network data packets via thread pool. A fresh task per + //dispatch (not a shared, per-call-mutated instance) so a re-entrant + //transmit/tune can't race the baseRig/message fields on the pool. + SafeExecutor.tryExecute(sendWaveDataThreadPool, + new SendWaveDataRunnable(baseRig, msg)); } } } @@ -902,9 +908,8 @@ public void onTransmitOverCAT(Ft8Message msg) {//send audio message via CAT if (!supportTransmitOverCAT()) { return; } - sendWaveDataRunnable.baseRig = baseRig; - sendWaveDataRunnable.message = msg; - SafeExecutor.tryExecute(sendWaveDataThreadPool, sendWaveDataRunnable); + SafeExecutor.tryExecute(sendWaveDataThreadPool, + new SendWaveDataRunnable(baseRig, msg)); } }, new OnTransmitSuccess() {//when QSO is successful @@ -2211,14 +2216,22 @@ public boolean isBTConnected() { } } - private static class GetQTHRunnable implements Runnable { - MainViewModel mainViewModel; - ArrayList messages; + // Per-dispatch, immutable payload. One instance per afterDecode() call — never shared + // and mutated between submissions — so overlapping decode passes (#398) each get their + // own message list processed instead of racing/clobbering a single shared field. + static final class GetQTHRunnable implements Runnable { + private final MainViewModel mainViewModel; + private final ArrayList messages; - public GetQTHRunnable(MainViewModel mainViewModel) { + GetQTHRunnable(MainViewModel mainViewModel, ArrayList messages) { this.mainViewModel = mainViewModel; + this.messages = messages; } + /** The decode pass this task resolves locations for (bound at construction). */ + ArrayList messages() { + return messages; + } @Override public void run() { @@ -2230,10 +2243,22 @@ public void run() { } } - private static class SendWaveDataRunnable implements Runnable { - BaseRig baseRig; - //float[] data; - Ft8Message message; + // Per-dispatch, immutable payload (see GetQTHRunnable): a fresh instance per transmit so + // a re-entrant TX/tune can't clobber baseRig/message on a worker still playing the prior + // waveform (the same shared-Runnable hazard fixed in IcomAudioUdp). + static final class SendWaveDataRunnable implements Runnable { + private final BaseRig baseRig; + private final Ft8Message message; + + SendWaveDataRunnable(BaseRig baseRig, Ft8Message message) { + this.baseRig = baseRig; + this.message = message; + } + + /** The message whose waveform this task sends (bound at construction). */ + Ft8Message message() { + return message; + } @Override public void run() { diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelPooledTaskTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelPooledTaskTest.java new file mode 100644 index 000000000..c231b32fd --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelPooledTaskTest.java @@ -0,0 +1,113 @@ +package com.k1af.ft8af; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.ArrayList; +import java.util.List; + +/** + * Regression coverage for the shared-mutable pooled-{@link Runnable} race in + * {@code MainViewModel.afterDecode()} (and the TX {@code onTransmit*} path). + * + *

    {@code afterDecode} is entered concurrently — slot N's late/deep pass and + * slot N+1's early pass overlap (#398), which is why {@code ft8Messages} and + * {@code decodeCycleState} are synchronized there. The QTH-lookup dispatch, however, used + * to reuse one {@code GetQTHRunnable} instance: each call did + * {@code getQTHRunnable.messages = messages} on the shared object and re-submitted it to a + * cached thread pool. When two passes overlapped, the second field write clobbered the + * first, so one pass's list never got its country/grid flags resolved + * ({@code getMessagesLocation}) and never fired its Needed-DX alerts + * ({@code dxAlertNotifier.processDecodes}) — the alerts were silently dropped, and two pool + * workers raced on the one non-volatile field. The fix binds the message list to a fresh + * task per dispatch ({@code new GetQTHRunnable(this, messages)}); the TX + * {@code SendWaveDataRunnable} got the same treatment. + * + *

    Robolectric is only needed to construct real {@link Ft8Message} rows (their ctor + * touches {@code android.util.Log}); the binding under test touches no Android types. + */ +@RunWith(RobolectricTestRunner.class) +public class MainViewModelPooledTaskTest { + + @Test + public void getQthRunnable_bindsItsOwnMessageList_perDispatch() { + // Two overlapping afterDecode passes, each with its own decoded-message list. + ArrayList passA = new ArrayList<>(); + ArrayList passB = new ArrayList<>(); + + MainViewModel.GetQTHRunnable a = new MainViewModel.GetQTHRunnable(null, passA); + // Constructing the second dispatch must NOT disturb the first. With the old shared + // instance, the second `getQTHRunnable.messages = passB` would have rebound both. + MainViewModel.GetQTHRunnable b = new MainViewModel.GetQTHRunnable(null, passB); + + assertThat(a.messages()).isSameInstanceAs(passA); + assertThat(b.messages()).isSameInstanceAs(passB); + assertThat(a.messages()).isNotSameInstanceAs(b.messages()); + } + + @Test + public void sendWaveDataRunnable_bindsItsOwnMessage_perDispatch() { + Ft8Message first = new Ft8Message(FT8Common.FT8_MODE); + Ft8Message second = new Ft8Message(FT8Common.FT8_MODE); + + MainViewModel.SendWaveDataRunnable a = + new MainViewModel.SendWaveDataRunnable(null, first); + MainViewModel.SendWaveDataRunnable b = + new MainViewModel.SendWaveDataRunnable(null, second); + + assertThat(a.message()).isSameInstanceAs(first); + assertThat(b.message()).isSameInstanceAs(second); + } + + @Test + public void sendWaveDataRunnable_nullRigOrMessage_isNoOp() { + // The guard survives the immutable rewrite: a task with no rig/message just returns. + new MainViewModel.SendWaveDataRunnable(null, null).run(); + new MainViewModel.SendWaveDataRunnable(null, new Ft8Message(FT8Common.FT8_MODE)).run(); + } + + /** + * Makes the dropped-pass hazard deterministic: a single shared payload holder overwritten + * before either "worker" runs loses the first pass's list, whereas a fresh task per + * dispatch preserves both. This is the exact shape of the production bug the fix removes. + */ + @Test + public void sharedInstance_dropsAPass_whilePerDispatchDoesNot() { + List processed = new ArrayList<>(); + + Object listA = new Object(); + Object listB = new Object(); + + // OLD wiring: one shared holder whose payload is overwritten by the 2nd dispatch, + // then both submitted Runnables read the (now single) shared payload. + MutableHolder shared = new MutableHolder(); + shared.payload = listA; // pass A dispatched + Runnable sharedDispatchA = () -> processed.add(shared.payload); + shared.payload = listB; // pass B clobbers A + Runnable sharedDispatchB = () -> processed.add(shared.payload); + sharedDispatchA.run(); + sharedDispatchB.run(); + assertThat(processed).containsExactly(listB, listB); // pass A's list was lost + + // NEW wiring: a fresh task bound to each pass's payload. + processed.clear(); + Object freshA = new Object(); + Object freshB = new Object(); + Runnable boundA = boundTask(processed, freshA); + Runnable boundB = boundTask(processed, freshB); + boundA.run(); + boundB.run(); + assertThat(processed).containsExactly(freshA, freshB); // both passes processed + } + + private static Runnable boundTask(List sink, Object payload) { + return () -> sink.add(payload); + } + + private static final class MutableHolder { + Object payload; + } +} From e7b1dfc724149a668678b4abce233d15080c73f6 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 23:17:17 -0500 Subject: [PATCH 076/113] Fix grid-tracker map leak: decoded-line direction animator never cancelled (#681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause ---------- GridOsmMapView.GridPolyLine's decoded-line constructor built the "marching ants" direction highlight as an infinite ValueAnimator (setRepeatCount(INFINITE), .start()) that was a bare local — never stored, never cancelled. The platform AnimationHandler keeps a strong reference to every running animator, and this one's update listener calls MapView.invalidate() every frame, so each orphaned animator (a) could never be GC'd, dragging its captured line and the whole MapView with it, and (b) kept forcing a full map redraw ~60x/second forever. clearLines() — invoked every decode cycle before the lines are rebuilt via drawLine() — removed each line from the overlay list but never cancelled its animator. On a busy band, dozens of new lines per 15 s slot accumulated as orphaned, still-running animators: steadily rising CPU/redraw load and a monotonic memory leak that ends in jank/ANR/OOM on a long-running session. Fix --- - Extract the animator into a small, testable DirectionLineAnimator (start/stop/isRunning) so its lifecycle can be unit-tested without an osmdroid MapView. GridPolyLine keeps it in a field and exposes stopAnimation(). - Cancel the animator at every point a line leaves the map: each discarded gridLines member in clearLines() (skipping the retained selectedLine, which is re-added and still visible), the selectedLine when its retention window elapses, and clearSelectedLines(). stop() is idempotent. - Only the Ft8Message constructor animates; the QSL-history line has no animator, so stopAnimation() is a no-op there. Behaviour of correct lines is unchanged — the animation looks and runs exactly as before, it just stops when the line disappears. Testing ------- - New DirectionLineAnimatorTest (Robolectric; ValueAnimator is an Android type): not-running until started, stop() cancels a running animator, stop() is idempotent, stop()-before-start() is safe. - Full ./gradlew :app:testDebugUnitTest passes (no regressions). Risk: low. Localized to the grid-tracker overlay teardown; no protocol, DSP, or decode-path behaviour changes. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../grid_tracker/DirectionLineAnimator.java | 65 +++++++++++++++++++ .../ft8af/grid_tracker/GridOsmMapView.java | 63 +++++++++++++----- .../DirectionLineAnimatorTest.java | 61 +++++++++++++++++ 3 files changed, 171 insertions(+), 18 deletions(-) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/grid_tracker/DirectionLineAnimator.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/grid_tracker/DirectionLineAnimatorTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/grid_tracker/DirectionLineAnimator.java b/ft8af/app/src/main/java/com/k1af/ft8af/grid_tracker/DirectionLineAnimator.java new file mode 100644 index 000000000..700fae4e6 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/grid_tracker/DirectionLineAnimator.java @@ -0,0 +1,65 @@ +package com.k1af.ft8af.grid_tracker; + +import android.animation.ValueAnimator; + +/** + * Owns the infinite "marching ants" {@link ValueAnimator} that animates the + * direction highlight sweeping along a decoded {@code GridPolyLine}. + * + *

    Extracted from {@code GridOsmMapView.GridPolyLine} so its lifecycle — in + * particular that {@link #stop()} cancels the infinite animator when the line is + * removed from the map — is unit-testable without an osmdroid {@code MapView}. The + * per-frame drawing math (which slice of the line to paint) stays in the line via + * the {@link OnProgress} callback; this class only owns the animator. + * + *

    Why it exists. The animator was previously a bare local: it was created + * with {@link ValueAnimator#INFINITE} repeat, started, and then never referenced + * again — so nothing ever cancelled it. The platform {@code AnimationHandler} keeps + * a strong reference to every running animator, so each orphaned animator (a) could + * never be garbage-collected, dragging its captured line and the whole + * {@code MapView} with it, and (b) kept firing its update callback — which calls + * {@code MapView.invalidate()} — roughly 60 times a second forever. The grid-tracker + * map rebuilds its lines every decode cycle ({@code clearLines()} then + * {@code drawLine()} per decode), so on a busy band the orphaned animators piled up + * without bound: steadily rising CPU/redraw load and a monotonic memory leak that + * ends in jank/ANR/OOM. Storing the animator here and cancelling it when the line + * leaves the map fixes that. + */ +final class DirectionLineAnimator { + + /** Per-frame callback; {@code fraction} sweeps {@code 0f..1f} and repeats. */ + interface OnProgress { + void onProgress(float fraction); + } + + private final ValueAnimator animator; + + DirectionLineAnimator(long durationMs, final OnProgress onProgress) { + animator = ValueAnimator.ofFloat(0f, 1f); + animator.setRepeatCount(ValueAnimator.INFINITE); + animator.setDuration(durationMs); + animator.setStartDelay(0); + animator.addUpdateListener(a -> onProgress.onProgress((float) a.getAnimatedValue())); + } + + /** Begin (or restart) the repeating animation. */ + void start() { + animator.start(); + } + + /** + * Cancel the animation and detach its update listener so the platform + * {@code AnimationHandler} releases the animator (and everything the update + * callback captured). Idempotent — safe to call more than once, and safe to + * call on a line that never started. + */ + void stop() { + animator.cancel(); + animator.removeAllUpdateListeners(); + } + + /** Whether {@link #start()} has run and {@link #stop()} has not since. */ + boolean isRunning() { + return animator.isStarted(); + } +} diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/grid_tracker/GridOsmMapView.java b/ft8af/app/src/main/java/com/k1af/ft8af/grid_tracker/GridOsmMapView.java index 7e1d4d19a..16edbbd94 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/grid_tracker/GridOsmMapView.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/grid_tracker/GridOsmMapView.java @@ -14,7 +14,6 @@ import static java.lang.Math.sin; import static java.lang.Math.tan; -import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Color; @@ -223,6 +222,7 @@ public GridPolyLine getSelectedLine() { public void clearSelectedLines() { if (selectedLine != null) { selectedLine.closeInfoWindow(); + selectedLine.stopAnimation();// removed from the map for good: cancel its animator gridMapView.getOverlays().remove(selectedLine); selectedLine = null; @@ -244,12 +244,25 @@ public synchronized void clearLines() { } for (GridPolyLine line : gridLines) { line.closeInfoWindow(); + // Cancel each discarded line's infinite direction animator before dropping it; + // otherwise every decode cycle's lines pile up as orphaned animators that pin + // the MapView and repaint ~60x/s forever (see DirectionLineAnimator). Skip the + // retained selectedLine (re-added just below and still visible) so it keeps + // animating. + if (line != selectedLine) { + line.stopAnimation(); + } gridMapView.getOverlays().remove(line); } gridLines.clear(); if (selectedLine != null && selectLineTimeOut > 0) { gridMapView.getOverlays().add(selectedLine); if (isOpening) selectedLine.showInfoWindow(); + } else if (selectedLine != null) { + // Its retention window has elapsed and it was not re-added above: it is gone + // from the map for good, so cancel its animator too rather than leak it. + selectedLine.stopAnimation(); + selectedLine = null; } gridMapView.invalidate(); } @@ -568,6 +581,13 @@ public static class GridPolyLine extends Polyline { public QSLRecordStr recorder; //public boolean marked = false; + // The infinite direction-highlight animator for a decoded line (null for a + // QSL-history line, which has none). Retained so it can be cancelled when the + // line is removed from the map — see stopAnimation() and clearLines(). Without + // this the animator was a bare local that ran forever and leaked. Package-private + // for GridOsmMapView's line-cleanup paths. + private DirectionLineAnimator pathAnimator; + @SuppressLint("DefaultLocale") public GridPolyLine(MapView mapView, LatLng fromLatLng, LatLng toLatLng, QSLRecordStr recordStr) { super(mapView); @@ -648,24 +668,31 @@ public GridPolyLine(MapView mapView, LatLng fromLatLng, LatLng toLatLng, Ft8Mess setMilestoneManagers(managers); - //Set up directional animation - final ValueAnimator percentageCompletion = ValueAnimator.ofFloat(0, 1); // 10 kilometers - - percentageCompletion.setRepeatCount(ValueAnimator.INFINITE); - percentageCompletion.setDuration(1000); // 1 seconds - percentageCompletion.setStartDelay(0); // 1 second - - percentageCompletion.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { - @Override - public void onAnimationUpdate(ValueAnimator animation) { - double dist = ((float) animation.getAnimatedValue()) * lineLen; - double distStart = dist - pointLen; - if (distStart < 0) distStart = 0; - slicerForPath.setMeterDistanceSlice(distStart, dist); - mapView.invalidate(); - } + //Set up directional animation. Retained in pathAnimator (not a bare local as + //before) so clearLines() can cancel it when the line leaves the map; an + //un-cancelled INFINITE animator leaks the line + MapView and repaints ~60x/s + //forever (see DirectionLineAnimator). + this.pathAnimator = new DirectionLineAnimator(1000, fraction -> { + double dist = fraction * lineLen; + double distStart = dist - pointLen; + if (distStart < 0) distStart = 0; + slicerForPath.setMeterDistanceSlice(distStart, dist); + mapView.invalidate(); }); - percentageCompletion.start(); + this.pathAnimator.start(); + } + + /** + * Stop and release this line's direction animation, if it has one. Called when + * the line is removed from the map (see {@link GridOsmMapView#clearLines()} and + * {@link GridOsmMapView#clearSelectedLines()}) so its infinite animator does not + * outlive the line. Idempotent; a QSL-history line has no animator, making this a + * no-op there. + */ + public void stopAnimation() { + if (pathAnimator != null) { + pathAnimator.stop(); + } } /** diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/grid_tracker/DirectionLineAnimatorTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/grid_tracker/DirectionLineAnimatorTest.java new file mode 100644 index 000000000..da2bf5713 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/grid_tracker/DirectionLineAnimatorTest.java @@ -0,0 +1,61 @@ +package com.k1af.ft8af.grid_tracker; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** + * Lifecycle tests for {@link DirectionLineAnimator}, the animator extracted from + * {@code GridOsmMapView.GridPolyLine} so its cancellation can be verified without an + * osmdroid MapView. + * + *

    The bug these pin: the direction animator used to be started and never + * cancelled, so every removed line left a running INFINITE animator behind (a leak + + * a ~60 Hz repaint that never stops). {@link DirectionLineAnimator#stop()} must turn + * a running animator off — that is what {@code clearLines()} now calls for each line + * it drops. {@code android.animation.ValueAnimator} is an Android type, hence + * Robolectric. + */ +@RunWith(RobolectricTestRunner.class) +public class DirectionLineAnimatorTest { + + @Test + public void newAnimator_isNotRunningUntilStarted() { + DirectionLineAnimator anim = new DirectionLineAnimator(1000, fraction -> { }); + // Construction alone must not start the animation. + assertThat(anim.isRunning()).isFalse(); + } + + @Test + public void start_thenStop_leavesAnimatorNotRunning() { + DirectionLineAnimator anim = new DirectionLineAnimator(1000, fraction -> { }); + anim.start(); + assertThat(anim.isRunning()).isTrue(); + // The fix: stopping the line's animator must actually cancel the infinite + // animation (before this it ran forever after the line was removed). + anim.stop(); + assertThat(anim.isRunning()).isFalse(); + } + + @Test + public void stop_isIdempotent() { + DirectionLineAnimator anim = new DirectionLineAnimator(1000, fraction -> { }); + anim.start(); + anim.stop(); + // clearLines() may reach a line whose animation is already stopped; a second + // stop() must be a harmless no-op, never an exception. + anim.stop(); + assertThat(anim.isRunning()).isFalse(); + } + + @Test + public void stop_beforeStart_isSafe() { + DirectionLineAnimator anim = new DirectionLineAnimator(1000, fraction -> { }); + // A line removed before its animation ever ran (or a QSL line routed here in + // future) must tolerate stop() without starting. + anim.stop(); + assertThat(anim.isRunning()).isFalse(); + } +} From 5b19b9edf575751407aded6d31ffab41b8441086 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 24 Jul 2026 01:17:42 -0500 Subject: [PATCH 077/113] Fix Kenwood TS-570/590/2000 + TX-500 CAT: replies never parsed (';'-framed but split on '\r') (#685) The Kenwood-family rigs (KenwoodTS570Rig, KenwoodTS590Rig, KenwoodTS2000Rig, and DiscoveryTX500Rig which inherits from TS2000) send every CAT command and reply terminated with ';' (FA;, RM;, FR0; -- see KenwoodTK90RigConstant), yet their onReceiveData split incoming bytes on '\r', which these rigs never send. So s.contains("\r") was never true: every reply was appended to the buffer until it passed 1000 chars and got wiped, and no reply was ever parsed. Consequences: - Frequency read-back was dead (FA replies never applied). - SWR/ALC over-power protection was dead -- the safety feature that halts TX on high SWR never received a reading. This is why the TX-500's SWR fix (#599/PR #626) never actually worked: PR #626 corrected the meter *layout* but the reply reached parsing through the inherited '\r' split and was dropped first. The reporter's own evidence in #599 (reply "RM10023;") and #589 ("control works normally except swr protection is not working at all", CAT chip goes red within seconds -- the signature of reads never parsing) confirm the ';' framing. This was a half-migration: the command constants were switched to ';'-terminated but onReceiveData was left splitting on '\r'. The same-repo Kenwood-family rigs TrUSDXRig (TS-480 emulation) and Flex6000Rig already split on ';'. Fix: add a multi-terminator overload CatLineSplitter.split(buffered, incoming, String terminators) -- any character in the set ends a command (the existing char overload delegates to it). The three Kenwood rigs adopt split(buffer, s, ";\r") and extract processCommand(String), draining every complete command in a read. Accepting both ';' and '\r' fixes the confirmed-broken ';' stream while being unable to regress any transport that might interleave a '\r' (Kenwood data never contains ';'; an empty span between ';' and '\r' becomes an empty frame that Yaesu3Command.getCommand treats as a no-op). Draining all frames per read also fixes the coalesced RM SWR+ALC reply drop, matching the CatLineSplitter adoption already done for the Yaesu/Elecraft rigs. DiscoveryTX500Rig is fixed via inheritance. Tests: 6 new CatLineSplitterTest cases covering the ';'-framed Kenwood reply, the coalesced SWR+ALC meter poll, freq+meter coalescing, cross-read reassembly, the stray-'\r' tolerance, and char/String overload agreement (19 total, all green). Full rig unit-test package green; main sources compile. Refs #599, #589. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/rigs/CatLineSplitter.java | 31 +++++++-- .../com/k1af/ft8af/rigs/KenwoodTS2000Rig.java | 56 +++++++++------- .../com/k1af/ft8af/rigs/KenwoodTS570Rig.java | 67 ++++++++++--------- .../com/k1af/ft8af/rigs/KenwoodTS590Rig.java | 67 ++++++++++--------- .../k1af/ft8af/rigs/CatLineSplitterTest.java | 64 ++++++++++++++++++ 5 files changed, 191 insertions(+), 94 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/CatLineSplitter.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/CatLineSplitter.java index a261c3102..74bb48068 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/CatLineSplitter.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/CatLineSplitter.java @@ -62,13 +62,36 @@ static final class Result { * @return the complete commands and the bytes to carry into the next call */ static Result split(String buffered, String incoming, char terminator) { + return split(buffered, incoming, String.valueOf(terminator)); + } + + /** + * Multi-terminator variant: any character in {@code terminators} ends a + * command. + * + *

    Some rigs' framing terminator is uncertain or historically inconsistent. + * The Kenwood family (TS-570/590/2000, Lab599 TX-500) sends {@code ';'}-framed + * commands and replies (per the Kenwood/Lab599 CAT protocol — e.g. the meter + * reply {@code RM10023;} documented in issue #599), yet the old per-rig + * reassembly split replies on {@code '\r'}, so no reply ever parsed: + * frequency read-back and — critically — SWR/ALC over-power protection were + * dead. Accepting {@code ";\r"} parses the real {@code ';'}-framed stream + * without regressing any transport that might interleave a {@code '\r'}. + * + * @param buffered bytes left over from the previous call (never null; may be empty) + * @param incoming newly received bytes (never null; may be empty) + * @param terminators the set of end-of-command characters (e.g. {@code ";\r"}) + * @return the complete commands and the bytes to carry into the next call + */ + static Result split(String buffered, String incoming, String terminators) { String combined = buffered + incoming; List frames = new ArrayList<>(); int start = 0; - int idx; - while ((idx = combined.indexOf(terminator, start)) >= 0) { - frames.add(combined.substring(start, idx)); - start = idx + 1; + for (int i = 0; i < combined.length(); i++) { + if (terminators.indexOf(combined.charAt(i)) >= 0) { + frames.add(combined.substring(start, i)); + start = i + 1; + } } return new Result(frames, combined.substring(start)); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTS2000Rig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTS2000Rig.java index 2ab74e55d..92611796b 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTS2000Rig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTS2000Rig.java @@ -136,35 +136,39 @@ public void setFreqToRig() { public void onReceiveData(byte[] data) { String s = new String(data); - if (!s.contains("\r")) { - buffer.append(s); - if (buffer.length() > 1000) clearBufferData(); - //return;//data reception not yet complete. - } else { - if (s.indexOf("\r") > 0) {//received end-of-data, and delimiter is not the first character - buffer.append(s.substring(0, s.indexOf("\r"))); - } - //begin parsing data - Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(buffer.toString()); - clearBufferData();//clear buffer - //put remaining data into buffer - buffer.append(s.substring(s.indexOf("\r") + 1)); + // Kenwood frames every command and reply with ';' (e.g. the freq reply + // FA...; and the meter reply RM...;). The old code split on '\r', which + // Kenwood never sends, so no reply ever parsed -- frequency read-back and + // SWR/ALC protection were dead (issues #599/#589 on the TX-500 subclass). + // Drain every complete ';'/CR-terminated command in this read (the RM + // poll answers SWR then ALC back-to-back, so both replies routinely + // coalesce into one read) and carry only the trailing, unterminated + // fragment into the next call. + CatLineSplitter.Result result = CatLineSplitter.split(buffer.toString(), s, ";\r"); + clearBufferData(); + buffer.append(result.remainder); + // A runaway unterminated buffer must not grow without bound. + if (buffer.length() > 1000) clearBufferData(); + + for (String frame : result.frames) { + processCommand(frame); + } + } - if (yaesu3Command == null) { - return; - } - String cmd = yaesu3Command.getCommandID(); - if (cmd.equalsIgnoreCase("FA")) {//frequency - long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); - if (tempFreq != 0) {//if tempFreq==0, frequency is invalid - setFreq(Yaesu3Command.getFrequency(yaesu3Command)); - } - } else if (cmd.equalsIgnoreCase("RM")) {//meter - handleMeterReply(yaesu3Command); + private void processCommand(String frame) { + Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(frame); + if (yaesu3Command == null) { + return; + } + String cmd = yaesu3Command.getCommandID(); + if (cmd.equalsIgnoreCase("FA")) {//frequency + long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); + if (tempFreq != 0) {//if tempFreq==0, frequency is invalid + setFreq(tempFreq); } - + } else if (cmd.equalsIgnoreCase("RM")) {//meter + handleMeterReply(yaesu3Command); } - } /** diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTS570Rig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTS570Rig.java index 7dac97a4c..84ff159b1 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTS570Rig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTS570Rig.java @@ -114,42 +114,45 @@ public void setFreqToRig() { public void onReceiveData(byte[] data) { String s = new String(data); - if (!s.contains("\r")) { - buffer.append(s); - if (buffer.length() > 1000) clearBufferData(); - //return;//data reception not yet complete. - } else { - if (s.indexOf("\r") > 0) {//received end-of-data, and delimiter is not the first character - buffer.append(s.substring(0, s.indexOf("\r"))); - } - //begin parsing data - Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(buffer.toString()); - clearBufferData();//clear buffer - //put remaining data into buffer - buffer.append(s.substring(s.indexOf("\r") + 1)); + // Kenwood frames every command and reply with ';' (e.g. the freq reply + // FA...; and the meter reply RM...;). The old code split on '\r', which + // Kenwood never sends, so no reply ever parsed -- frequency read-back and + // SWR/ALC protection were dead. Drain every complete ';'/CR-terminated + // command in this read (the RM poll answers SWR then ALC back-to-back, so + // both replies routinely coalesce into one read) and carry only the + // trailing, unterminated fragment into the next call. + CatLineSplitter.Result result = CatLineSplitter.split(buffer.toString(), s, ";\r"); + clearBufferData(); + buffer.append(result.remainder); + // A runaway unterminated buffer must not grow without bound. + if (buffer.length() > 1000) clearBufferData(); + + for (String frame : result.frames) { + processCommand(frame); + } + } - if (yaesu3Command == null) { - return; + private void processCommand(String frame) { + Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(frame); + if (yaesu3Command == null) { + return; + } + String cmd = yaesu3Command.getCommandID(); + if (cmd.equalsIgnoreCase("FA")) {//frequency + long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); + if (tempFreq != 0) {//if tempFreq==0, frequency is invalid + setFreq(tempFreq); } - String cmd = yaesu3Command.getCommandID(); - if (cmd.equalsIgnoreCase("FA")) {//frequency - long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); - if (tempFreq != 0) {//if tempFreq==0, frequency is invalid - setFreq(Yaesu3Command.getFrequency(yaesu3Command)); - } - } else if (cmd.equalsIgnoreCase("RM")) {//meter - if (Yaesu3Command.is590MeterSWR(yaesu3Command)) { - swr = Yaesu3Command.get590ALCOrSWR(yaesu3Command); - } - if (Yaesu3Command.is590MeterALC(yaesu3Command)) { - alc = Yaesu3Command.get590ALCOrSWR(yaesu3Command); - } - showAlert(); - notifyMeterData(Math.min(alc * 8, 255), Math.min(swr * 8, 255)); + } else if (cmd.equalsIgnoreCase("RM")) {//meter + if (Yaesu3Command.is590MeterSWR(yaesu3Command)) { + swr = Yaesu3Command.get590ALCOrSWR(yaesu3Command); } - + if (Yaesu3Command.is590MeterALC(yaesu3Command)) { + alc = Yaesu3Command.get590ALCOrSWR(yaesu3Command); + } + showAlert(); + notifyMeterData(Math.min(alc * 8, 255), Math.min(swr * 8, 255)); } - } private void showAlert() { diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTS590Rig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTS590Rig.java index 952eceff9..61d33a878 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTS590Rig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/KenwoodTS590Rig.java @@ -126,42 +126,45 @@ public void setFreqToRig() { public void onReceiveData(byte[] data) { String s = new String(data); - if (!s.contains("\r")) { - buffer.append(s); - if (buffer.length() > 1000) clearBufferData(); - //return;//data reception not yet complete. - } else { - if (s.indexOf("\r") > 0) {//received end-of-data, and delimiter is not the first character - buffer.append(s.substring(0, s.indexOf("\r"))); - } - //begin parsing data - Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(buffer.toString()); - clearBufferData();//clear buffer - //put remaining data into buffer - buffer.append(s.substring(s.indexOf("\r") + 1)); + // Kenwood frames every command and reply with ';' (e.g. the freq reply + // FA...; and the meter reply RM...;). The old code split on '\r', which + // Kenwood never sends, so no reply ever parsed -- frequency read-back and + // SWR/ALC protection were dead. Drain every complete ';'/CR-terminated + // command in this read (the RM poll answers SWR then ALC back-to-back, so + // both replies routinely coalesce into one read) and carry only the + // trailing, unterminated fragment into the next call. + CatLineSplitter.Result result = CatLineSplitter.split(buffer.toString(), s, ";\r"); + clearBufferData(); + buffer.append(result.remainder); + // A runaway unterminated buffer must not grow without bound. + if (buffer.length() > 1000) clearBufferData(); + + for (String frame : result.frames) { + processCommand(frame); + } + } - if (yaesu3Command == null) { - return; + private void processCommand(String frame) { + Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(frame); + if (yaesu3Command == null) { + return; + } + String cmd = yaesu3Command.getCommandID(); + if (cmd.equalsIgnoreCase("FA")) {//frequency + long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); + if (tempFreq != 0) {//if tempFreq==0, frequency is invalid + setFreq(tempFreq); } - String cmd = yaesu3Command.getCommandID(); - if (cmd.equalsIgnoreCase("FA")) {//frequency - long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); - if (tempFreq != 0) {//if tempFreq==0, frequency is invalid - setFreq(Yaesu3Command.getFrequency(yaesu3Command)); - } - } else if (cmd.equalsIgnoreCase("RM")) {//meter - if (Yaesu3Command.is590MeterSWR(yaesu3Command)) { - swr = Yaesu3Command.get590ALCOrSWR(yaesu3Command); - } - if (Yaesu3Command.is590MeterALC(yaesu3Command)) { - alc = Yaesu3Command.get590ALCOrSWR(yaesu3Command); - } - showAlert(); - notifyMeterData(Math.min(alc * 8, 255), Math.min(swr * 8, 255)); + } else if (cmd.equalsIgnoreCase("RM")) {//meter + if (Yaesu3Command.is590MeterSWR(yaesu3Command)) { + swr = Yaesu3Command.get590ALCOrSWR(yaesu3Command); } - + if (Yaesu3Command.is590MeterALC(yaesu3Command)) { + alc = Yaesu3Command.get590ALCOrSWR(yaesu3Command); + } + showAlert(); + notifyMeterData(Math.min(alc * 8, 255), Math.min(swr * 8, 255)); } - } private void showAlert() { diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java index 5d49c1158..f7f5e9411 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java @@ -137,4 +137,68 @@ public void carriageReturnTerminator_supportedForSiblingRigs() { assertThat(r.frames).containsExactly("IF00014074000", "FA1").inOrder(); assertThat(r.remainder).isEmpty(); } + + // --- Multi-terminator overload: the Kenwood family (TS-570/590/2000, TX-500) + // frames replies with ';' but the old per-rig handlers split on '\r', so no + // reply ever parsed (freq read-back + SWR/ALC protection dead, issues + // #599/#589). These rigs adopt split(..., ";\r") to parse the real ';' stream + // while still tolerating a stray '\r'. + + @Test + public void kenwoodSemicolonReply_parsedByMultiTerminatorSplit() { + // The exact reply the TX-500 reporter documented in #599: RM10023; -- a + // ';'-terminated meter frame with no '\r'. The old '\r' split never saw a + // terminator and buffered it forever, so SWR stayed 0 and protection never + // tripped. The ";\r" split yields the frame. + CatLineSplitter.Result r = CatLineSplitter.split("", "RM10023;", ";\r"); + assertThat(r.frames).containsExactly("RM10023"); + assertThat(r.remainder).isEmpty(); + } + + @Test + public void kenwoodMeterPoll_swrAndAlcCoalesced_bothSurvive() { + // A single RM; poll on the TS-590 answers the SWR (RM1) and ALC (RM3) + // meters back-to-back; both must be delivered so neither reading is stale. + CatLineSplitter.Result r = CatLineSplitter.split("", "RM10023;RM30150;", ";\r"); + assertThat(r.frames).containsExactly("RM10023", "RM30150").inOrder(); + assertThat(r.remainder).isEmpty(); + } + + @Test + public void kenwoodFreqThenMeterCoalesced_bothParsedInOrder() { + CatLineSplitter.Result r = CatLineSplitter.split("", "FA00014074000;RM10023;", ";\r"); + assertThat(r.frames).containsExactly("FA00014074000", "RM10023").inOrder(); + assertThat(r.remainder).isEmpty(); + } + + @Test + public void multiTerminator_semicolonSplitAcrossReads_reassembled() { + CatLineSplitter.Result first = CatLineSplitter.split("", "FA000140", ";\r"); + assertThat(first.frames).isEmpty(); + assertThat(first.remainder).isEqualTo("FA000140"); + + CatLineSplitter.Result second = CatLineSplitter.split(first.remainder, "74000;", ";\r"); + assertThat(second.frames).containsExactly("FA00014074000"); + assertThat(second.remainder).isEmpty(); + } + + @Test + public void multiTerminator_toleratesStrayCarriageReturnAroundSemicolon() { + // If a transport ever interleaves a '\r' (CR/LF-style framing), the empty + // span between ';' and '\r' is an empty frame the rig treats as a no-op + // (Yaesu3Command.getCommand returns null for <2 chars), and the real + // command is still delivered intact -- so accepting both cannot regress a + // '\r'-influenced stream. + CatLineSplitter.Result r = CatLineSplitter.split("", "RM10023;\r", ";\r"); + assertThat(r.frames).containsExactly("RM10023", "").inOrder(); + assertThat(r.remainder).isEmpty(); + } + + @Test + public void charAndStringOverloadsAgree_forSingleTerminator() { + CatLineSplitter.Result viaChar = CatLineSplitter.split("", "FA1;FB2;", ';'); + CatLineSplitter.Result viaString = CatLineSplitter.split("", "FA1;FB2;", ";"); + assertThat(viaString.frames).containsExactlyElementsIn(viaChar.frames).inOrder(); + assertThat(viaString.remainder).isEqualTo(viaChar.remainder); + } } From 092319d90e9f53c93e4d2b60788bcf23773da684 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 24 Jul 2026 03:18:09 -0500 Subject: [PATCH 078/113] Fix DX/watchlist alerts permanently lost when first heard before notification permission granted (#686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: DxAlertNotifier.fire() consumed the per-session dedup key (`alerted.add(dedupKey)`) BEFORE the Android-13+ POST_NOTIFICATIONS permission gate. When notifications aren't granted, the key was burned and the method returned without posting. Because `alerted` lives for the whole process and is never cleared, once the key was burned the same station never fired again that session — so any new-DXCC / new-state / watchlist / CQ-reply alert for a station first heard during the permission-denied window was suppressed forever, even after the user granted permission. Fix: extract the gate ordering into the pure, unit-testable AlertDecisions.claimAlert(set, key, canPost) helper that checks the permission gate first and only consumes the dedup key when a notification can actually be posted. Also release the key on the SecurityException (permission-revoked-mid-notify) path so a later decode can retry instead of staying suppressed. The misleading "ALERT fire" log now only writes when a notification is actually posted. Tests: new AlertDecisionsTest cases cover the postable/dedup/denied paths and the grant-after-denied regression; both bug-capturing cases fail against the old ordering and pass with the fix. Full testDebugUnitTest suite green. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/alert/AlertDecisions.java | 24 +++++++++++ .../com/k1af/ft8af/alert/DxAlertNotifier.java | 20 ++++++---- .../k1af/ft8af/alert/AlertDecisionsTest.java | 40 +++++++++++++++++++ 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/alert/AlertDecisions.java b/ft8af/app/src/main/java/com/k1af/ft8af/alert/AlertDecisions.java index 6c32918bb..028908503 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/alert/AlertDecisions.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/alert/AlertDecisions.java @@ -1,5 +1,7 @@ package com.k1af.ft8af.alert; +import java.util.Set; + /** * Pure, Android-free decision + dedup logic for {@link DxAlertNotifier}, extracted so the * branching can be unit-tested without a device (the notifier itself touches @@ -19,6 +21,28 @@ public final class AlertDecisions { private AlertDecisions() {} + /** + * Claim the right to post an alert for {@code dedupKey}, returning {@code true} only when + * the caller should proceed to post. + * + *

    The permission gate ({@code canPost}) is evaluated before the dedup set is + * touched. Consuming the key while notifications are denied would permanently suppress the + * station: {@code alerted} lives for the whole process and is never cleared, so once the + * key is burned the same station never fires again this session — even after the user + * grants POST_NOTIFICATIONS. Gate first, then dedup, so a station first heard during the + * permission-denied window still alerts on its next decode once permission is granted. + * + * @param alerted per-session set of already-claimed dedup keys; mutated only when this + * returns {@code true} + * @param dedupKey namespaced key for this alert + * @param canPost whether a notification can actually be posted right now (permission + * present / pre-Android-13) + */ + public static boolean claimAlert(Set alerted, String dedupKey, boolean canPost) { + if (!canPost) return false; // gate FIRST — do not burn the key while denied + return alerted.add(dedupKey); // consume the key only when we can post + } + /** * @param enabled the {@code alertOnCqReply} user setting * @param addressedToMe whether the decoded message's target callsign is mine diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java b/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java index 5a37b9f97..e40472adf 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java @@ -191,16 +191,17 @@ private static String cqReplyBody(Ft8Message msg) { */ private void fire(String dedupKey, String channelId, String title, String body, String callsignExtra, long bandExtra) { - if (!alerted.add(dedupKey)) return; // already alerted this session + // Gate on notification permission BEFORE consuming the dedup key. If we burned the + // key here while notifications are denied, the per-session `alerted` set (never + // cleared) would suppress this station forever — even after the user later grants + // POST_NOTIFICATIONS. claimAlert() checks `canPost` first, then dedups. + boolean canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU + || ContextCompat.checkSelfPermission(appContext, + Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED; + if (!AlertDecisions.claimAlert(alerted, dedupKey, canPost)) return; GeneralVariables.fileLog("ALERT fire key=[" + dedupKey + "] " + title); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU - && ContextCompat.checkSelfPermission(appContext, - Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - return; // no permission → skip silently - } - int id = dedupKey.hashCode(); Intent intent = new Intent(appContext, @@ -228,7 +229,10 @@ private void fire(String dedupKey, String channelId, String title, String body, try { NotificationManagerCompat.from(appContext).notify(id, builder.build()); } catch (SecurityException ignored) { - // Permission revoked between the check and notify(); ignore. + // Permission revoked between the check and notify(): nothing was posted, so + // release the key to let a later decode of the same station retry rather than + // stay suppressed for the session. + alerted.remove(dedupKey); } } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/alert/AlertDecisionsTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/alert/AlertDecisionsTest.java index f19005dc4..3f2e40826 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/alert/AlertDecisionsTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/alert/AlertDecisionsTest.java @@ -4,9 +4,49 @@ import org.junit.Test; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + /** Pure-JVM tests for {@link AlertDecisions} (no Android runtime needed). */ public class AlertDecisionsTest { + private static Set newAlertedSet() { + return Collections.newSetFromMap(new ConcurrentHashMap<>()); + } + + @Test + public void claimAlert_firesAndConsumesKeyWhenPostable() { + Set alerted = newAlertedSet(); + assertThat(AlertDecisions.claimAlert(alerted, "DXCC:Japan", true)).isTrue(); + assertThat(alerted).contains("DXCC:Japan"); + } + + @Test + public void claimAlert_dedupesRepeatKeyWhenPostable() { + Set alerted = newAlertedSet(); + assertThat(AlertDecisions.claimAlert(alerted, "DXCC:Japan", true)).isTrue(); + assertThat(AlertDecisions.claimAlert(alerted, "DXCC:Japan", true)).isFalse(); + } + + @Test + public void claimAlert_doesNotBurnKeyWhenNotPostable() { + Set alerted = newAlertedSet(); + assertThat(AlertDecisions.claimAlert(alerted, "DXCC:Japan", false)).isFalse(); + // The permission gate must run BEFORE the dedup set is touched, so the key is + // untouched and the station can still alert once permission is granted. + assertThat(alerted).isEmpty(); + } + + @Test + public void claimAlert_firesAfterPermissionGrantedForStationHeardWhileDenied() { + Set alerted = newAlertedSet(); + // Station first heard while notifications are denied — must not be suppressed. + assertThat(AlertDecisions.claimAlert(alerted, "WATCH:3Y0J", false)).isFalse(); + // User grants permission; the same station decodes again and now alerts. + assertThat(AlertDecisions.claimAlert(alerted, "WATCH:3Y0J", true)).isTrue(); + } + @Test public void cqReply_firesOnlyWhenEnabledAddressedAndNotBlocked() { assertThat(AlertDecisions.shouldAlertCqReply(true, true, false)).isTrue(); From 796f82b5577ca2814de065e95ac80ad89ba69e3f Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 24 Jul 2026 03:18:19 -0500 Subject: [PATCH 079/113] Add a live clock-sync (DT) indicator to the slot-timer bar (#687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FT8 only tolerates ~2 s of clock error before decoding collapses in both directions: a mis-set clock stops you decoding others and pushes your own transmissions to the edge of everyone else's receive window, so nobody spots you either. The app already measured this — FT8SignalListener posts the mean decode DT per cycle to MainViewModel.mutableTimerOffset — but it only ever surfaced inside Settings → Time Sync, so an operator with a drifting clock had no on-screen hint that timing was the problem. Surface it on the always-visible slot-timer bar as a compact color-coded pill: a dot + the "DT" label (WSJT-X's term) + the signed average offset. Green when |DT| <= 0.3 s (in sync), amber up to 1.0 s (check clock), red beyond that (clock off). The mean DT of the stations you decode is a good proxy for your own clock offset, since most stations are themselves synced. The pill only appears once the first decode cycle reports a DT, so the bar is visually unchanged until there's real data. Decision/format logic is extracted into pure, unit-tested functions (clockSyncLevel / clockSyncOffsetLabel / clockSyncColor); the composable is a thin drawing wrapper with an accessibility contentDescription. SlotTimerBar gains a nullable offsetSec parameter (defaulted, backward compatible). Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt | 9 +- .../ks3ckc/ft8af/ui/components/ClockSync.kt | 141 ++++++++++++++++++ .../ft8af/ui/components/SlotTimerBar.kt | 6 + .../src/main/res/values/strings_compose.xml | 9 ++ .../ft8af/ui/components/ClockSyncTest.kt | 59 ++++++++ 5 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSync.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSyncTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt index 4a6cf69f5..c6772f89e 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt @@ -229,6 +229,10 @@ fun FT8AFApp(mainViewModel: MainViewModel) { val operatingMode by mainViewModel.mutableOperatingMode.observeAsState(GeneralVariables.operatingMode) val modeName = ModeProfile.fromId(operatingMode).displayName + // Mean decode DT (seconds) for the slot-timer bar's live clock-sync pill; null until + // the first decode cycle reports one, so the bar renders unchanged before then. + val avgDtSec by mainViewModel.mutableTimerOffset.observeAsState() + // Observe SWR lockout state val swrLocked by mainViewModel.meterProtectionController.swrLockout.observeAsState(false) val lockoutSwrRatio by mainViewModel.meterProtectionController.lockoutSwrRatio.observeAsState("") @@ -352,9 +356,12 @@ fun FT8AFApp(mainViewModel: MainViewModel) { qsoPanel(Modifier) } - // Slot timer bar — fills 0→100% across each slot (15s FT8 / 7.5s FT4) + // Slot timer bar — fills 0→100% across each slot (15s FT8 / 7.5s FT4). + // The mean decode DT rides on the bar as a live clock-sync pill so the + // operator can spot a drifting clock without opening Settings → Time Sync. SlotTimerBar( slotMillis = ModeProfile.fromId(operatingMode).slotMillis.toLong(), + offsetSec = avgDtSec, ) // TX status strip — always visible above tab bar diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSync.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSync.kt new file mode 100644 index 000000000..2df1b0046 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSync.kt @@ -0,0 +1,141 @@ +package radio.ks3ckc.ft8af.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.k1af.ft8af.R +import radio.ks3ckc.ft8af.theme.GeistMonoFamily +import radio.ks3ckc.ft8af.theme.StatusBad +import radio.ks3ckc.ft8af.theme.StatusConfirmed +import radio.ks3ckc.ft8af.theme.StatusWarn +import radio.ks3ckc.ft8af.theme.TextMuted +import java.util.Locale +import kotlin.math.abs + +/** + * Clock-sync health derived from the mean decode DT (time offset, in seconds). + * + * FT8 tolerates only ~2 s of clock error before decoding collapses — both ways: a + * mis-set clock stops you decoding others *and* pushes your transmissions to the edge + * of everyone else's receive window so they can't decode you either. Yet the app never + * surfaced this on the operating screen; the operator had to open Settings → Time Sync + * to notice. The mean DT of the stations you decode is a good proxy for your own clock + * offset: most stations are themselves synced, so a consistent bias across all of them + * is almost certainly yours. + */ +internal enum class ClockSyncLevel { GOOD, FAIR, POOR, UNKNOWN } + +/** |DT| at/below this (seconds) is a healthy, in-the-sweet-spot clock. */ +internal const val CLOCK_SYNC_GOOD_SEC = 0.3f + +/** |DT| at/below this (seconds) still decodes but is worth a resync; above it is POOR. */ +internal const val CLOCK_SYNC_FAIR_SEC = 1.0f + +/** + * Classify the mean decode DT into a sync-health level. A `null` value means no decode + * cycle has reported yet this session; a non-finite value is treated the same way so a bad + * reading can never mis-color the pill. + */ +internal fun clockSyncLevel(offsetSec: Float?): ClockSyncLevel { + if (offsetSec == null || offsetSec.isNaN() || offsetSec.isInfinite()) return ClockSyncLevel.UNKNOWN + val mag = abs(offsetSec) + return when { + mag <= CLOCK_SYNC_GOOD_SEC -> ClockSyncLevel.GOOD + mag <= CLOCK_SYNC_FAIR_SEC -> ClockSyncLevel.FAIR + else -> ClockSyncLevel.POOR + } +} + +/** + * Signed, one-decimal-second DT label (WSJT-X style), e.g. "+0.1 s", "-1.3 s", "0.0 s", + * or an em dash when unknown. A value that rounds to zero renders unsigned (never "-0.0 s"). + */ +internal fun clockSyncOffsetLabel(offsetSec: Float?): String { + if (offsetSec == null || offsetSec.isNaN() || offsetSec.isInfinite()) return "—" + val mag = String.format(Locale.US, "%.1f", abs(offsetSec)) + val sign = when { + mag == "0.0" -> "" + offsetSec < 0f -> "-" + else -> "+" + } + return "$sign$mag s" +} + +/** Green in-sync, amber worth-a-resync, red clock-off, grey unknown. */ +internal fun clockSyncColor(level: ClockSyncLevel): Color = when (level) { + ClockSyncLevel.GOOD -> StatusConfirmed + ClockSyncLevel.FAIR -> StatusWarn + ClockSyncLevel.POOR -> StatusBad + ClockSyncLevel.UNKNOWN -> TextMuted +} + +/** Short status word resource for accessibility / screen readers. */ +internal fun clockSyncStatusText(level: ClockSyncLevel): Int = when (level) { + ClockSyncLevel.GOOD -> R.string.clock_sync_good + ClockSyncLevel.FAIR -> R.string.clock_sync_fair + ClockSyncLevel.POOR -> R.string.clock_sync_poor + ClockSyncLevel.UNKNOWN -> R.string.clock_sync_unknown +} + +/** + * Compact clock-sync pill for the slot-timer bar: a color-coded dot, the "DT" label + * operators know from WSJT-X, and the signed offset. All decision/format logic lives in + * the plain functions above; this composable only draws and wires accessibility. + */ +@Composable +internal fun ClockSyncIndicator( + offsetSec: Float?, + modifier: Modifier = Modifier, +) { + val level = clockSyncLevel(offsetSec) + val color = clockSyncColor(level) + val offsetLabel = clockSyncOffsetLabel(offsetSec) + val statusWord = stringResource(clockSyncStatusText(level)) + val label = stringResource(R.string.clock_sync_label) + val cd = stringResource(R.string.clock_sync_cd, offsetLabel, statusWord) + + Row( + modifier = modifier.semantics { contentDescription = cd }, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background(color), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = label, + color = TextMuted, + fontFamily = GeistMonoFamily, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.06.sp, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = offsetLabel, + color = color, + fontFamily = GeistMonoFamily, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/SlotTimerBar.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/SlotTimerBar.kt index f5b80b2e9..7e47800b8 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/SlotTimerBar.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/SlotTimerBar.kt @@ -34,6 +34,9 @@ import radio.ks3ckc.ft8af.theme.TextMuted @Composable fun SlotTimerBar( slotMillis: Long = 15_000L, + // Mean decode DT (seconds) for the live clock-sync pill; null until the first decode + // cycle reports, in which case no pill is shown (the bar looks exactly as before). + offsetSec: Float? = null, modifier: Modifier = Modifier, ) { var nowMs by remember { mutableLongStateOf(UtcTimer.getSystemTime()) } @@ -56,6 +59,9 @@ fun SlotTimerBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { + if (offsetSec != null) { + ClockSyncIndicator(offsetSec = offsetSec) + } Box( modifier = Modifier .weight(1f) diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 1e8590c16..11f47ad53 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -743,6 +743,15 @@ High Clip + + DT + in sync + check clock + clock off + no decodes + + Clock sync: average decode offset %1$s, %2$s + Language App display language diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSyncTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSyncTest.kt new file mode 100644 index 000000000..083a1bd62 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/ClockSyncTest.kt @@ -0,0 +1,59 @@ +package radio.ks3ckc.ft8af.ui.components + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** Pure-JVM tests for the clock-sync classifier/formatter (no Compose/Android needed). */ +class ClockSyncTest { + + @Test + fun level_goodWhenNearZero() { + assertThat(clockSyncLevel(0f)).isEqualTo(ClockSyncLevel.GOOD) + assertThat(clockSyncLevel(0.1f)).isEqualTo(ClockSyncLevel.GOOD) + assertThat(clockSyncLevel(-0.2f)).isEqualTo(ClockSyncLevel.GOOD) + // Boundary: exactly the GOOD threshold is still GOOD. + assertThat(clockSyncLevel(0.3f)).isEqualTo(ClockSyncLevel.GOOD) + assertThat(clockSyncLevel(-0.3f)).isEqualTo(ClockSyncLevel.GOOD) + } + + @Test + fun level_fairInTheMiddleBand() { + assertThat(clockSyncLevel(0.31f)).isEqualTo(ClockSyncLevel.FAIR) + assertThat(clockSyncLevel(-0.7f)).isEqualTo(ClockSyncLevel.FAIR) + // Boundary: exactly the FAIR threshold is still FAIR. + assertThat(clockSyncLevel(1.0f)).isEqualTo(ClockSyncLevel.FAIR) + assertThat(clockSyncLevel(-1.0f)).isEqualTo(ClockSyncLevel.FAIR) + } + + @Test + fun level_poorWhenFarFromZero() { + assertThat(clockSyncLevel(1.01f)).isEqualTo(ClockSyncLevel.POOR) + assertThat(clockSyncLevel(-2.4f)).isEqualTo(ClockSyncLevel.POOR) + } + + @Test + fun level_unknownForNullOrNonFinite() { + assertThat(clockSyncLevel(null)).isEqualTo(ClockSyncLevel.UNKNOWN) + assertThat(clockSyncLevel(Float.NaN)).isEqualTo(ClockSyncLevel.UNKNOWN) + assertThat(clockSyncLevel(Float.POSITIVE_INFINITY)).isEqualTo(ClockSyncLevel.UNKNOWN) + } + + @Test + fun offsetLabel_signedToOneDecimalSecond() { + assertThat(clockSyncOffsetLabel(0.14f)).isEqualTo("+0.1 s") + assertThat(clockSyncOffsetLabel(-1.25f)).isEqualTo("-1.3 s") + } + + @Test + fun offsetLabel_zeroHasNoSign() { + assertThat(clockSyncOffsetLabel(0f)).isEqualTo("0.0 s") + // A value that rounds to zero should also render unsigned, not "-0.0 s". + assertThat(clockSyncOffsetLabel(-0.02f)).isEqualTo("0.0 s") + } + + @Test + fun offsetLabel_dashWhenUnknown() { + assertThat(clockSyncOffsetLabel(null)).isEqualTo("—") + assertThat(clockSyncOffsetLabel(Float.NaN)).isEqualTo("—") + } +} From f8fc1f64cb4763286feafabe9a38be2e892970d7 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 24 Jul 2026 05:18:04 -0500 Subject: [PATCH 080/113] Add a "New Zone" decode highlight + filter for Worked All Zones chasers (#688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces stations calling from a CQ zone the operator hasn't logged yet — the core of the WAZ (Worked All Zones) award, one of DXing's most prestigious. The decode-time unworked-zone flag (Ft8Message.fromCq) was already computed alongside fromDxcc but only ever consumed by the retired legacy list adapter; the modern Compose decode UI never showed it. - New "New Zone" filter chip: one-tap "who's calling from a zone I still need" view (base.filter { checkIsCQ() && fromCq }), mirroring "New DXCC". - New NEW_ZONE status pill (sky-blue), shown when highlightNewZone is on. Priority sits between NEW (DXCC) and NEW_GRID — only 40 CQ zones exist, so an unworked one is a rarer catch than a grid but still yields to the headline DXCC badge. - Settings → Decode Highlights toggle (highlightNewZone, persisted via writeConfig / loaded in DatabaseOpr), defaulting on like New DXCC. No decoder or transmit code paths change. Tests: NewZoneTest (pill + priority vs NEW/NEW_GRID), DecodeFilterTest (filter cases), DecodeScreenTest (empty-state copy). Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/GeneralVariables.java | 1 + .../com/k1af/ft8af/database/DatabaseOpr.java | 3 + .../kotlin/radio/ks3ckc/ft8af/theme/Color.kt | 3 + .../ks3ckc/ft8af/ui/components/StatusPill.kt | 6 + .../radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt | 5 + .../ks3ckc/ft8af/ui/decode/DecodeScreen.kt | 9 +- .../ft8af/ui/settings/DecodeFilterSettings.kt | 14 +++ .../src/main/res/values/strings_compose.xml | 6 + .../ft8af/ui/decode/DecodeFilterTest.kt | 22 ++++ .../ft8af/ui/decode/DecodeScreenTest.kt | 11 ++ .../ks3ckc/ft8af/ui/decode/NewZoneTest.kt | 110 ++++++++++++++++++ 11 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/NewZoneTest.kt diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 54646e933..9a220664f 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -576,6 +576,7 @@ public static String excludedBandsToCsv() { // Decode-list highlight toggles (Settings → Decode Highlights). Gate the // status pill shown for each worked-before category in resolveQsoStatus(). public static boolean highlightNewDxcc = true;//Highlight stations from an unworked DXCC entity + public static boolean highlightNewZone = true;//Highlight stations from an unworked CQ zone (Worked All Zones) public static boolean highlightNewGrid = false;//Off by default — most grids are "new", so it's noisy public static boolean highlightNewBand = true;//Highlight stations worked only on other bands public static boolean highlightWorked = true;//Master enable for worked-station handling (see workedStationMode) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 646d7cebd..f9d5c50df 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -3135,6 +3135,9 @@ protected Void doInBackground(Void... voids) { if (name.equalsIgnoreCase("highlightNewDxcc")) { GeneralVariables.highlightNewDxcc = result.equals("1"); } + if (name.equalsIgnoreCase("highlightNewZone")) { + GeneralVariables.highlightNewZone = result.equals("1"); + } if (name.equalsIgnoreCase("highlightNewGrid")) { GeneralVariables.highlightNewGrid = result.equals("1"); } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/theme/Color.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/theme/Color.kt index 196e654bf..056c464ce 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/theme/Color.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/theme/Color.kt @@ -157,6 +157,9 @@ val StatusWorked = Color(0xFF5CD6E8) val StatusConfirmed = Color(0xFF4ADE80) val StatusCq = Color(0xFFFFAF5E) val StatusWarn = Color(0xFFFACC15) +// Sky-blue, distinct from the purple NEW-DXCC and cyan NEW-BAND pills, for the +// NEW-ZONE (Worked All Zones) badge. +val StatusZone = Color(0xFF60A5FA) val StatusBad = Color(0xFFEF4444) // Band colors (for donut chart / logbook) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/StatusPill.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/StatusPill.kt index 4dfa02c49..00e0a4d04 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/StatusPill.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/StatusPill.kt @@ -36,6 +36,12 @@ enum class QsoStatus( Color(0x1FC084FC), // rgba(192,132,252,0.12) Color(0x47C084FC), // rgba(192,132,252,0.28) ), + NEW_ZONE( + R.string.status_new_zone, + StatusZone, + Color(0x1F60A5FA), // rgba(96,165,250,0.12) + Color(0x4760A5FA), // rgba(96,165,250,0.28) + ), NEW_GRID( R.string.status_new_grid, StatusWarn, diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt index 58e361a57..8fe60ae4b 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt @@ -400,6 +400,11 @@ internal fun resolveQsoStatus(message: Ft8Message): QsoStatus? { if (newPota) QsoStatus.NEW_POTA else QsoStatus.POTA isCQ && modifier == "SOTA" -> QsoStatus.SOTA GeneralVariables.highlightNewDxcc && message.fromDxcc -> QsoStatus.NEW + // A new CQ zone (Worked All Zones) outranks a new grid: only 40 zones + // exist, so an unworked one is a rarer, more prized catch. message.fromCq + // is set at decode time in CallsignDatabase (unworked-zone lookup) and + // only ever true once the zone map is ready — same gating as fromDxcc. + GeneralVariables.highlightNewZone && message.fromCq -> QsoStatus.NEW_ZONE GeneralVariables.highlightNewGrid && newGrid -> QsoStatus.NEW_GRID GeneralVariables.highlightNewBand && newBand -> QsoStatus.NEW_BAND effectiveWorkedMode() == WorkedStationMode.HIGHLIGHT && diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt index acb09e928..f60458b5a 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt @@ -67,7 +67,7 @@ fun DecodeScreen( // Filter state. Backed by the ViewModel so the chosen filter survives // navigation away from Decode and back (the screen is recreated by the // tab switch, which would otherwise reset a local rememberSaveable). - val filterOptions = listOf("All", "CQ Calls", "CQ POTA", "New DXCC", "New Grid", "Needed", "For Me") + val filterOptions = listOf("All", "CQ Calls", "CQ POTA", "New DXCC", "New Zone", "New Grid", "Needed", "For Me") val selectedFilter by mainViewModel.decodeFilter.observeAsState("All") // Couple the "CQ POTA" display filter to Hunt: while it's selected, the auto-call @@ -522,6 +522,7 @@ private fun filterLabel(key: String): String = when (key) { "CQ Calls" -> stringResource(R.string.decode_filter_cq_calls) "CQ POTA" -> stringResource(R.string.decode_filter_cq_pota) "New DXCC" -> stringResource(R.string.decode_filter_new_dxcc) + "New Zone" -> stringResource(R.string.decode_filter_new_zone) "New Grid" -> stringResource(R.string.decode_filter_new_grid) "Needed" -> stringResource(R.string.decode_filter_needed) "For Me" -> stringResource(R.string.decode_filter_for_me) @@ -539,6 +540,7 @@ private fun filterLabel(key: String): String = when (key) { * - All: no filtering * - CQ Calls: only CQ messages * - New DXCC: CQ from a DXCC entity not yet in the operator's worked list + * - New Zone: CQ from a CQ zone not yet in the operator's worked list (WAZ) * - New Grid: CQ from a Maidenhead grid field not yet in the operator's worked list * - Needed: need QSL confirmation (not in QSL callsign list) * - For Me: callsignTo matches operator's callsign @@ -588,6 +590,10 @@ internal fun filterMessages( // this filter and hunting agree on who counts as a POTA station — see issue #333. "CQ POTA" -> base.filter { radio.ks3ckc.ft8af.pota.PotaCqClassifier.isPotaCq(it) } "New DXCC" -> base.filter { it.checkIsCQ() && it.fromDxcc } + // Mirror of "New DXCC" for zone chasers (Worked All Zones): only CQ + // stations from a CQ zone the operator hasn't logged yet. fromCq is the + // decode-time unworked-zone flag, computed alongside fromDxcc. + "New Zone" -> base.filter { it.checkIsCQ() && it.fromCq } // Mirror of "New DXCC" for grid chasers (VUCC / grid hunting): only CQ // stations whose grid field the operator hasn't logged yet, so the list // becomes a one-tap "who's calling from a grid I still need" view. @@ -616,6 +622,7 @@ internal fun EmptyState( "CQ Calls" -> stringResource(R.string.decode_empty_cq_title) to stringResource(R.string.decode_empty_cq_body) "CQ POTA" -> stringResource(R.string.decode_empty_pota_title) to stringResource(R.string.decode_empty_pota_body) "New DXCC" -> stringResource(R.string.decode_empty_dxcc_title) to stringResource(R.string.decode_empty_dxcc_body) + "New Zone" -> stringResource(R.string.decode_empty_zone_title) to stringResource(R.string.decode_empty_zone_body) "New Grid" -> stringResource(R.string.decode_empty_grid_title) to stringResource(R.string.decode_empty_grid_body) "Needed" -> stringResource(R.string.decode_empty_needed_title) to stringResource(R.string.decode_empty_needed_body) "For Me" -> stringResource(R.string.decode_empty_forme_title) to stringResource(R.string.decode_empty_forme_body) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt index 3d7e59231..c50fd45ac 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt @@ -27,6 +27,7 @@ fun DecodeFilterSettings( ) { // Decode-list highlight toggles var highlightNewDxcc by remember { mutableStateOf(GeneralVariables.highlightNewDxcc) } + var highlightNewZone by remember { mutableStateOf(GeneralVariables.highlightNewZone) } var highlightNewGrid by remember { mutableStateOf(GeneralVariables.highlightNewGrid) } var highlightNewBand by remember { mutableStateOf(GeneralVariables.highlightNewBand) } var highlightWorked by remember { mutableStateOf(GeneralVariables.highlightWorked) } @@ -244,6 +245,19 @@ fun DecodeFilterSettings( }, ) SectionDivider() + SettingsRow( + label = stringResource(R.string.settings_highlight_new_zone), + description = stringResource(R.string.settings_highlight_new_zone_desc), + toggle = highlightNewZone, + onToggleChange = { checked -> + highlightNewZone = checked + GeneralVariables.highlightNewZone = checked + mainViewModel.databaseOpr.writeConfig( + "highlightNewZone", if (checked) "1" else "0", null, + ) + }, + ) + SectionDivider() SettingsRow( label = stringResource(R.string.settings_highlight_new_grid), description = stringResource(R.string.settings_highlight_new_grid_desc), diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 11f47ad53..ce198d3af 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -46,6 +46,7 @@ NEW DXCC + NEW ZONE NEW GRID NEW BAND POTA @@ -174,6 +175,7 @@ CQ Calls CQ POTA New DXCC + New Zone New Grid Needed For Me @@ -183,6 +185,8 @@ No park activations decoded on this band yet — open POTA → Hunt for the spot list. No new DXCC No unworked DXCC entities have been decoded yet. + No new zones + No stations calling from an unworked CQ zone have been decoded yet. No new grids No stations calling from an unworked grid square have been decoded yet. Nothing needed @@ -571,6 +575,8 @@ New DXCC Highlight stations from an unworked DXCC entity + New Zone + Highlight not-yet-worked CQ zones (Worked All Zones) New Grid Highlight not-yet-worked Maidenhead grids New Band diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeFilterTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeFilterTest.kt index 3800b3b6e..9d320a49b 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeFilterTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeFilterTest.kt @@ -78,6 +78,28 @@ class DecodeFilterTest { assertThat(result).containsExactly(fresh) } + @Test + fun newZone_keepsCqFromUnworkedZone() { + // fromCq is the decode-time "unworked CQ zone" flag; the filter keeps + // only CQ stations that carry it. + val fresh = cq("K1ABC").apply { fromCq = true } + val worked = cq("K2DEF").apply { fromCq = false } + + val result = filterMessages(listOf(fresh, worked), "New Zone") + + assertThat(result).containsExactly(fresh) + } + + @Test + fun newZone_dropsDirectedMessages() { + // A directed reply isn't a callable CQ even if it's from a new zone. + val directedNewZone = directed("W1AW", "K2DEF").apply { fromCq = true } + + val result = filterMessages(listOf(directedNewZone), "New Zone") + + assertThat(result).isEmpty() + } + @Test fun newGrid_dropsDirectedAndGridlessMessages() { val directedNewGrid = directed("W1AW", "K2DEF").apply { maidenGrid = "EN37" } diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreenTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreenTest.kt index 3b93c8aef..035c818f8 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreenTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreenTest.kt @@ -55,6 +55,17 @@ class DecodeScreenTest { composeRule.onNodeWithText(title).assertIsDisplayed() } + @Test + fun newZoneFilter_showsZoneEmptyCopy() { + val title = context.getString(R.string.decode_empty_zone_title) + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + EmptyState(selectedFilter = "New Zone") + } + + composeRule.onNodeWithText(title).assertIsDisplayed() + } + @Test fun newGridFilter_showsGridEmptyCopy() { val title = context.getString(R.string.decode_empty_grid_title) diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/NewZoneTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/NewZoneTest.kt new file mode 100644 index 000000000..59dec6d9d --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/NewZoneTest.kt @@ -0,0 +1,110 @@ +package radio.ks3ckc.ft8af.ui.decode + +import com.k1af.ft8af.Ft8Message +import com.k1af.ft8af.GeneralVariables +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import radio.ks3ckc.ft8af.ui.components.QsoStatus + +/** + * Unit-tests the NEW_ZONE (Worked All Zones) decode-row pill wired off the + * decode-time [Ft8Message.fromCq] flag, plus its priority relative to the + * neighbouring NEW (DXCC) and NEW_GRID badges. The filter side is covered in + * [DecodeFilterTest]; this keeps the two in agreement on what counts as an + * unworked CQ zone. + * + * Robolectric is needed only because [Ft8Message] reaches android.util.Log on + * construction; the logic under test is pure. + */ +@RunWith(RobolectricTestRunner::class) +class NewZoneTest { + + private var savedHighlightPota = false + private var savedHighlightNewDxcc = false + private var savedHighlightNewZone = false + private var savedHighlightNewGrid = false + private var savedHighlightNewBand = false + private var savedHighlightWorked = false + + @Before + fun setUp() { + savedHighlightPota = GeneralVariables.highlightPota + savedHighlightNewDxcc = GeneralVariables.highlightNewDxcc + savedHighlightNewZone = GeneralVariables.highlightNewZone + savedHighlightNewGrid = GeneralVariables.highlightNewGrid + savedHighlightNewBand = GeneralVariables.highlightNewBand + savedHighlightWorked = GeneralVariables.highlightWorked + GeneralVariables.QSL_Grid_list.clear() + GeneralVariables.QSL_Callsign_list.clear() + // Isolate the branch under test: only the zone highlight is on unless a + // test opts another in. + GeneralVariables.highlightPota = false + GeneralVariables.highlightNewDxcc = false + GeneralVariables.highlightNewZone = true + GeneralVariables.highlightNewGrid = false + GeneralVariables.highlightNewBand = false + GeneralVariables.highlightWorked = false + } + + @After + fun tearDown() { + GeneralVariables.highlightPota = savedHighlightPota + GeneralVariables.highlightNewDxcc = savedHighlightNewDxcc + GeneralVariables.highlightNewZone = savedHighlightNewZone + GeneralVariables.highlightNewGrid = savedHighlightNewGrid + GeneralVariables.highlightNewBand = savedHighlightNewBand + GeneralVariables.highlightWorked = savedHighlightWorked + GeneralVariables.QSL_Grid_list.clear() + GeneralVariables.QSL_Callsign_list.clear() + } + + private fun cq(from: String): Ft8Message = Ft8Message("CQ", from, "TEST") + + @Test + fun newZonePill_surfacesWhenHighlightEnabledAndFromCq() { + val msg = cq("K1ABC").apply { fromCq = true } + assertThat(resolveQsoStatus(msg)).isEqualTo(QsoStatus.NEW_ZONE) + } + + @Test + fun newZonePill_hiddenWhenHighlightDisabled() { + GeneralVariables.highlightNewZone = false + val msg = cq("K1ABC").apply { fromCq = true } + // Falls through to the plain CQ badge instead of NEW_ZONE. + assertThat(resolveQsoStatus(msg)).isEqualTo(QsoStatus.CQ) + } + + @Test + fun newZonePill_hiddenWhenZoneAlreadyWorked() { + val msg = cq("K1ABC").apply { fromCq = false } + assertThat(resolveQsoStatus(msg)).isEqualTo(QsoStatus.CQ) + } + + @Test + fun newDxcc_outranksNewZone() { + // A station that is both a new DXCC and a new zone shows the DXCC badge — + // DXCC is the headline award and NEW is higher priority. + GeneralVariables.highlightNewDxcc = true + val msg = cq("K1ABC").apply { + fromDxcc = true + fromCq = true + } + assertThat(resolveQsoStatus(msg)).isEqualTo(QsoStatus.NEW) + } + + @Test + fun newZone_outranksNewGrid() { + // Only 40 CQ zones exist, so an unworked one is rarer than an unworked + // grid — NEW_ZONE wins when both apply. + GeneralVariables.highlightNewGrid = true + val msg = cq("K1ABC").apply { + fromCq = true + maidenGrid = "FN42" // unworked grid (QSL_Grid_list is empty) + } + assertThat(resolveQsoStatus(msg)).isEqualTo(QsoStatus.NEW_ZONE) + } +} From 5f036f39356cc5ff5daefcdd84994a78c68d2f65 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 24 Jul 2026 10:19:24 -0500 Subject: [PATCH 081/113] Pileup: option to work the strongest waiting caller next (+ SNR on caller chips) (#692) When running a pileup (calling CQ and getting several answers at once during a DXpedition, POTA activation, or contest), the caller queue always worked the oldest waiting station first (FIFO). On a busy run the best-copy stations then wait behind marginal ones, wasting cycles on exchanges that may not complete. Add an opt-in Auto-Sequence setting, "Work strongest caller first", that makes the auto-dequeue pick the highest-SNR waiting caller next instead (ties break toward the earliest-queued, so equal signals keep first-heard order). The selection policy is extracted to a pure, unit-tested CallerQueueOrdering.pickNextIndex; default is off, preserving the historic FIFO behavior for anyone who prefers it. The on-screen caller-queue chips now also show each waiting caller's SNR and sort strongest-first to match, so the operator can see at a glance who the engine will work next (and tap to pick someone else). - GeneralVariables.pileupStrongestFirst (DB key pileupStrongestFirst, default off) - CallerQueueOrdering pure policy; FT8TransmitSignal.dequeueNextCaller uses it - ActiveQsoPanel: orderCallerQueue + callerSnrLabel helpers, SNR chip label - Tests: CallerQueueOrderingTest (Java), CallerQueueDisplayTest (Kotlin) Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/GeneralVariables.java | 3 + .../com/k1af/ft8af/database/DatabaseOpr.java | 4 + .../ft8transmit/CallerQueueOrdering.java | 55 ++++++++ .../ft8af/ft8transmit/FT8TransmitSignal.java | 7 +- .../ft8af/ui/components/ActiveQsoPanel.kt | 39 +++++- .../ft8af/ui/settings/TransmissionSettings.kt | 14 ++ .../src/main/res/values/strings_compose.xml | 2 + .../ft8transmit/CallerQueueOrderingTest.java | 124 ++++++++++++++++++ .../ui/components/CallerQueueDisplayTest.kt | 62 +++++++++ 9 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/CallerQueueOrdering.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/CallerQueueOrderingTest.java create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/CallerQueueDisplayTest.kt diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 9a220664f..1c741932f 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -550,6 +550,9 @@ public static String excludedBandsToCsv() { // transmit/decode processing thread (FT8TransmitSignal), like zoneMapReady above. public static volatile boolean huntPotaOnly = false;//Mirror of the "CQ POTA" decode filter: Hunt only calls POTA CQs (issue #333) public static boolean autoCallFollow = true;//Auto-call followed callsigns + // volatile: written from the Compose settings UI thread and read from the + // transmit thread (FT8TransmitSignal.dequeueNextCaller), like huntPotaOnly above. + public static volatile boolean pileupStrongestFirst = false;//Pileup: auto-work the strongest waiting caller next instead of the oldest (FIFO) public static boolean autoUpdateGridFromGPS = false;//Use device GPS to keep Maidenhead grid current public static boolean disciplineClockFromGPS = false;//Discipline the app clock (UtcTimer.delay) from GPS satellite time (issue #373). Off by default — consensual. public static int gpsClockIntervalMinutes = 5;//How often to re-read GPS time for clock discipline. Clamped 1-30 by GpsClockUpdater. diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index f9d5c50df..8892f83e1 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2754,6 +2754,10 @@ protected Void doInBackground(Void... voids) { GeneralVariables.clearDecodesEveryCycle = result.equals("1"); } + if (name.equalsIgnoreCase("pileupStrongestFirst")) { + GeneralVariables.pileupStrongestFirst = result.equals("1"); + } + if (name.equalsIgnoreCase("decodeSortMode")) { // parseConfigInt, not Integer.parseInt: a whitespace/non-numeric value in // the config table would otherwise throw during startup hydration. diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/CallerQueueOrdering.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/CallerQueueOrdering.java new file mode 100644 index 000000000..d28a94b84 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/CallerQueueOrdering.java @@ -0,0 +1,55 @@ +package com.k1af.ft8af.ft8transmit; + +import java.util.List; + +/** + * Pileup caller-selection policy: given the queue of stations waiting to be + * worked after the current QSO, decide which one to pick next. + * + *

    Two policies: + *

      + *
    • First-heard (FIFO) — the default. The station that has been + * waiting longest (index 0, the head of the queue) is worked next. This + * is the fairest order and matches the historic behavior.
    • + *
    • Strongest-first — the waiting caller with the highest SNR is + * worked next, so a busy DXpedition/POTA/contest run completes the + * best-copy exchanges first and wastes fewer cycles on marginal signals. + * Ties break toward the earliest-queued caller (lowest index), so equal + * signals are still worked in the order they called.
    • + *
    + * + *

    Pure and side-effect free so the policy is unit-testable without the + * transmit engine or any Android types. + */ +public final class CallerQueueOrdering { + + private CallerQueueOrdering() { + } + + /** + * Index into {@code queue} of the caller to work next, or {@code -1} when the + * queue is empty/null. + * + * @param queue the pending callers, head-first (index 0 = queued earliest) + * @param strongestFirst when true, pick the highest-SNR caller; otherwise FIFO (index 0) + */ + public static int pickNextIndex(List queue, boolean strongestFirst) { + if (queue == null || queue.isEmpty()) { + return -1; + } + if (!strongestFirst) { + return 0; + } + int bestIndex = 0; + int bestSnr = queue.get(0).snr; + for (int i = 1; i < queue.size(); i++) { + int snr = queue.get(i).snr; + // Strictly greater keeps ties on the earliest-queued caller. + if (snr > bestSnr) { + bestSnr = snr; + bestIndex = i; + } + } + return bestIndex; + } +} diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java index 208abd094..1099c879c 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java @@ -2435,7 +2435,12 @@ public void enqueueCaller(Ft8Message msg) { public boolean dequeueNextCaller() { synchronized (callerQueue) { while (!callerQueue.isEmpty()) { - QueuedCaller caller = callerQueue.remove(0); + // Pileup pick order: FIFO by default, or the strongest waiting + // caller when the operator has turned that on (issue #333 sibling). + int idx = CallerQueueOrdering.pickNextIndex( + callerQueue, GeneralVariables.pileupStrongestFirst); + if (idx < 0) break; + QueuedCaller caller = callerQueue.remove(idx); mutableCallerQueue.postValue(new ArrayList<>(callerQueue)); // Skip if caller is now excluded or already worked diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ActiveQsoPanel.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ActiveQsoPanel.kt index fff53a67c..ec816256f 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ActiveQsoPanel.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ActiveQsoPanel.kt @@ -669,6 +669,26 @@ private fun TxSelector( } } +/** + * Order the pileup caller chips for display. When [strongestFirst] the queue is + * sorted by SNR descending (best copy first) — matching the auto-dequeue policy + * in [com.k1af.ft8af.ft8transmit.CallerQueueOrdering] — so the chip the operator + * is nudged to tap next is the same station the engine would pick. [sortedByDescending] + * is stable, so equal-SNR callers keep their first-heard order. Off preserves the + * raw queue (first-heard) order. Extracted for unit testing. + */ +internal fun orderCallerQueue( + queue: List, + strongestFirst: Boolean, +): List = + if (strongestFirst) queue.sortedByDescending { it.snr } else queue + +/** + * Signed SNR label for a queued caller chip (e.g. "+5", "-12"). A queued caller + * with no reported SNR carries 0, which renders as "+0". + */ +internal fun callerSnrLabel(snr: Int): String = if (snr >= 0) "+$snr" else "$snr" + @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @Composable private fun CallerQueueBar( @@ -679,6 +699,12 @@ private fun CallerQueueBar( Spacer(modifier = Modifier.height(6.dp)) + // Display order tracks the auto-dequeue policy so the leftmost chip is the + // station the engine would work next. Computed inline (the queue is capped at + // ~10 callers) rather than remember()d, so an SNR update that mutates a queued + // caller in place still re-sorts on the next recomposition. + val ordered = orderCallerQueue(queue, GeneralVariables.pileupStrongestFirst) + Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -697,8 +723,8 @@ private fun CallerQueueBar( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(3.dp), ) { - queue.forEach { caller -> - Box( + ordered.forEach { caller -> + Row( modifier = Modifier .clip(RoundedCornerShape(4.dp)) .background(SignalSoft) @@ -707,7 +733,8 @@ private fun CallerQueueBar( else Modifier ) .padding(horizontal = 6.dp, vertical = 2.dp), - contentAlignment = Alignment.Center, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Text( text = caller.callsign, @@ -716,6 +743,12 @@ private fun CallerQueueBar( fontWeight = FontWeight.Medium, fontFamily = GeistMonoFamily, ) + Text( + text = callerSnrLabel(caller.snr), + color = Signal.copy(alpha = 0.65f), + fontSize = 9.sp, + fontFamily = GeistMonoFamily, + ) } } } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TransmissionSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TransmissionSettings.kt index 7a40e9f2c..65b4b29ef 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TransmissionSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TransmissionSettings.kt @@ -114,6 +114,7 @@ fun TransmissionSettings( var autoCallFollow by remember { mutableStateOf(GeneralVariables.autoCallFollow) } var earlyDecode by remember { mutableStateOf(GeneralVariables.earlyDecode) } var autoCQAfterQSO by remember { mutableStateOf(GeneralVariables.autoCQAfterQSO) } + var pileupStrongestFirst by remember { mutableStateOf(GeneralVariables.pileupStrongestFirst) } var showWatchdog by remember { mutableStateOf(false) } var showStopAfter by remember { mutableStateOf(false) } @@ -680,6 +681,19 @@ fun TransmissionSettings( ) }, ) + SectionDivider() + SettingsRow( + label = stringResource(R.string.settings_pileup_strongest), + description = stringResource(R.string.settings_pileup_strongest_desc), + toggle = pileupStrongestFirst, + onToggleChange = { checked -> + pileupStrongestFirst = checked + GeneralVariables.pileupStrongestFirst = checked + mainViewModel.databaseOpr.writeConfig( + "pileupStrongestFirst", if (checked) "1" else "0", null, + ) + }, + ) } } } diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index ce198d3af..0dfa3ed8c 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -571,6 +571,8 @@ Decode ~1s earlier so you can answer a CQ on the next slot instead of waiting. May miss stations with a large clock offset. Auto-CQ after QSO Keep calling CQ automatically after each completed contact — no re-tap. Stays on your frequency (ignores Hunt). Runs until you stop or the TX Watchdog times out with no answers. + Work strongest caller first + In a pileup, auto-work the strongest waiting caller next instead of the one who called earliest. Completes the best-copy contacts first on a busy run. The caller queue lists callers strongest-first to match. New DXCC diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/CallerQueueOrderingTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/CallerQueueOrderingTest.java new file mode 100644 index 000000000..7093b3642 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/CallerQueueOrderingTest.java @@ -0,0 +1,124 @@ +package com.k1af.ft8af.ft8transmit; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.junit.Test; + +/** + * Unit tests for {@link CallerQueueOrdering#pickNextIndex} — the pileup + * caller-selection policy (first-heard FIFO vs. strongest-SNR-first). + */ +public class CallerQueueOrderingTest { + + private static QueuedCaller caller(String call, int snr) { + return new QueuedCaller(call, 1000f, 0, snr, 0, 0, ""); + } + + @Test + public void emptyQueue_returnsMinusOne() { + assertThat(CallerQueueOrdering.pickNextIndex(new ArrayList<>(), false)).isEqualTo(-1); + assertThat(CallerQueueOrdering.pickNextIndex(new ArrayList<>(), true)).isEqualTo(-1); + } + + @Test + public void nullQueue_returnsMinusOne() { + assertThat(CallerQueueOrdering.pickNextIndex(null, true)).isEqualTo(-1); + } + + @Test + public void fifo_alwaysPicksHead() { + List q = new ArrayList<>(); + q.add(caller("A", -5)); + q.add(caller("B", +12)); // stronger, but FIFO ignores SNR + q.add(caller("C", 0)); + assertThat(CallerQueueOrdering.pickNextIndex(q, false)).isEqualTo(0); + } + + @Test + public void strongestFirst_picksHighestSnr() { + List q = new ArrayList<>(); + q.add(caller("A", -5)); + q.add(caller("B", +12)); + q.add(caller("C", +3)); + assertThat(CallerQueueOrdering.pickNextIndex(q, true)).isEqualTo(1); + } + + @Test + public void strongestFirst_negativeSnrs_picksLeastNegative() { + List q = new ArrayList<>(); + q.add(caller("A", -20)); + q.add(caller("B", -3)); + q.add(caller("C", -15)); + assertThat(CallerQueueOrdering.pickNextIndex(q, true)).isEqualTo(1); + } + + @Test + public void strongestFirst_tieBreaksToEarliest() { + List q = new ArrayList<>(); + q.add(caller("A", +7)); + q.add(caller("B", +7)); // equal SNR, queued later + assertThat(CallerQueueOrdering.pickNextIndex(q, true)).isEqualTo(0); + } + + @Test + public void strongestFirst_singleCaller() { + List q = new ArrayList<>(); + q.add(caller("A", -30)); + assertThat(CallerQueueOrdering.pickNextIndex(q, true)).isEqualTo(0); + } + + @Test + public void strongestFirst_headIsAlreadyStrongest() { + List q = new ArrayList<>(); + q.add(caller("A", +15)); + q.add(caller("B", +2)); + assertThat(CallerQueueOrdering.pickNextIndex(q, true)).isEqualTo(0); + } + + @Test + public void draining_strongestFirst_yieldsDescendingOrder() { + // Simulate repeatedly picking-and-removing to confirm the whole pileup + // drains strongest-first. + List q = new ArrayList<>(); + q.add(caller("A", -5)); + q.add(caller("B", +12)); + q.add(caller("C", +3)); + q.add(caller("D", -18)); + + List worked = new ArrayList<>(); + while (!q.isEmpty()) { + int idx = CallerQueueOrdering.pickNextIndex(q, true); + worked.add(q.remove(idx).callsign); + } + assertThat(worked).containsExactly("B", "C", "A", "D").inOrder(); + } + + @Test + public void draining_fifo_yieldsQueueOrder() { + List q = new ArrayList<>(); + q.add(caller("A", -5)); + q.add(caller("B", +12)); + q.add(caller("C", +3)); + + List worked = new ArrayList<>(); + while (!q.isEmpty()) { + int idx = CallerQueueOrdering.pickNextIndex(q, false); + worked.add(q.remove(idx).callsign); + } + assertThat(worked).containsExactly("A", "B", "C").inOrder(); + } + + @Test + public void pickNextIndex_doesNotMutateQueue() { + List q = new ArrayList<>(); + q.add(caller("A", -5)); + q.add(caller("B", +12)); + List snapshot = Collections.unmodifiableList(new ArrayList<>(q)); + CallerQueueOrdering.pickNextIndex(q, true); + assertThat(q).containsExactlyElementsIn(snapshot).inOrder(); + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/CallerQueueDisplayTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/CallerQueueDisplayTest.kt new file mode 100644 index 000000000..75e7f673d --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/CallerQueueDisplayTest.kt @@ -0,0 +1,62 @@ +package radio.ks3ckc.ft8af.ui.components + +import com.google.common.truth.Truth.assertThat +import com.k1af.ft8af.ft8transmit.QueuedCaller +import org.junit.Test + +/** + * Unit tests for the pileup caller-chip display helpers [orderCallerQueue] and + * [callerSnrLabel]. The display order must mirror the auto-dequeue policy + * (CallerQueueOrdering) so the leftmost chip is the station the engine works next. + */ +class CallerQueueDisplayTest { + + private fun caller(call: String, snr: Int) = + QueuedCaller(call, 1000f, 0, snr, 0, 0, "") + + @Test + fun fifo_preservesQueueOrder() { + val q = listOf(caller("A", -5), caller("B", 12), caller("C", 3)) + val ordered = orderCallerQueue(q, strongestFirst = false) + assertThat(ordered.map { it.callsign }).containsExactly("A", "B", "C").inOrder() + } + + @Test + fun strongestFirst_sortsBySnrDescending() { + val q = listOf(caller("A", -5), caller("B", 12), caller("C", 3)) + val ordered = orderCallerQueue(q, strongestFirst = true) + assertThat(ordered.map { it.callsign }).containsExactly("B", "C", "A").inOrder() + } + + @Test + fun strongestFirst_stableForTies() { + // Equal SNR keeps first-heard order (sortedByDescending is stable). + val q = listOf(caller("A", 7), caller("B", 7), caller("C", 7)) + val ordered = orderCallerQueue(q, strongestFirst = true) + assertThat(ordered.map { it.callsign }).containsExactly("A", "B", "C").inOrder() + } + + @Test + fun orderCallerQueue_doesNotMutateInput() { + val q = listOf(caller("A", -5), caller("B", 12)) + orderCallerQueue(q, strongestFirst = true) + assertThat(q.map { it.callsign }).containsExactly("A", "B").inOrder() + } + + @Test + fun emptyQueue_returnsEmpty() { + assertThat(orderCallerQueue(emptyList(), strongestFirst = true)).isEmpty() + assertThat(orderCallerQueue(emptyList(), strongestFirst = false)).isEmpty() + } + + @Test + fun snrLabel_positiveGetsPlusSign() { + assertThat(callerSnrLabel(5)).isEqualTo("+5") + assertThat(callerSnrLabel(0)).isEqualTo("+0") + } + + @Test + fun snrLabel_negativeKeepsMinus() { + assertThat(callerSnrLabel(-12)).isEqualTo("-12") + } +} From 3f1d9ffdce357967609739da16cfcba340e7e399 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 24 Jul 2026 10:24:54 -0500 Subject: [PATCH 082/113] Fix PSKReporter dedup map growing without bound (slow memory leak) (#682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix PSKReporter dedup map growing without bound (slow memory leak) The PskReporterSender singleton keeps a per-"CALL|BAND" last-seen map so a station is uploaded at most once per 5-minute window. Entries were added on every fresh spot but never removed, so over a long monitoring session (the common use case: leave the app decoding a busy band for hours or days) the map grew one entry per distinct callsign/band ever heard — an unbounded, slow leak in a process-lifetime object. An entry older than the dedup window carries no information: markIfFresh() would report that key as fresh again regardless, so dropping it is behavior-neutral. Add pruneDedup(nowMs) to evict aged-out entries and call it from flush() (once per send interval, before draining the queue, so it fires even on a quiet band), keeping the map bounded to roughly the distinct stations seen in the last window. Tests: 4 new PskReporterSenderTest cases covering the eviction boundary (exactly window-old evicts, one ms short is kept), empty-map no-op, and that flush() prunes. Full :app:testDebugUnitTest green. Co-Authored-By: Claude Opus 4.8 (1M context) * PskReporter: clarify pruneDedup KDoc matches the inclusive >= boundary Wording said entries age 'past' the window, but eviction (and markIfFresh treating a spot as fresh again) both use age >= DEDUP_WINDOW_MS, i.e. the exact boundary is included. Doc-only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../ft8af/pskreporter/PskReporterSender.kt | 37 +++++++++++++ .../pskreporter/PskReporterSenderTest.kt | 53 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSender.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSender.kt index cbae3a1dc..35df0efd5 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSender.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSender.kt @@ -250,8 +250,45 @@ object PskReporterSender { } } + /** + * Evict dedup entries whose age has reached or passed [DEDUP_WINDOW_MS]. + * + * The dedup map records a last-seen timestamp per "CALL|BAND" so a station is + * uploaded at most once per window. Entries are added on every fresh spot, but + * were never removed — so over a long monitoring session (the common + * PSKReporter use case: leave the app decoding a busy band for hours or days) + * the map grew without bound, one entry per distinct callsign/band ever heard. + * Because this is a process-lifetime singleton, that is a slow, unbounded leak. + * + * An entry at or past the window boundary carries no information: [markIfFresh] + * would report that key as fresh again regardless (it treats age + * `>= DEDUP_WINDOW_MS` as no longer a duplicate), so dropping it is + * behavior-neutral. + * Called from [flush] (once per send interval), which keeps the map bounded to + * roughly the distinct stations seen within the last window. Pure w.r.t. + * [nowMs] so it is deterministically testable. + */ + @VisibleForTesting + internal fun pruneDedup(nowMs: Long) { + synchronized(dedup) { + val it = dedup.entries.iterator() + while (it.hasNext()) { + if (nowMs - it.next().value >= DEDUP_WINDOW_MS) it.remove() + } + } + } + + /** For testing: current number of live dedup entries. */ + @VisibleForTesting + internal fun dedupSize(): Int = synchronized(dedup) { dedup.size } + @VisibleForTesting internal suspend fun flush() { + // Keep the dedup map bounded: drop keys that have aged out of the window + // (see pruneDedup). Runs every send interval, before draining the queue, so + // it fires even when the band is quiet and no spots are pending. + pruneDedup(System.currentTimeMillis()) + val spots = mutableListOf() while (true) { val s = spotQueue.poll() ?: break diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSenderTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSenderTest.kt index d7cb2f05b..cc3090da7 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSenderTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSenderTest.kt @@ -628,6 +628,59 @@ class PskReporterSenderTest { assertThat(admitted.get()).isEqualTo(threadCount * perThread) } + // --------------------------------------------------------------- + // Dedup map eviction (pruneDedup) — bounded-memory guarantee + // --------------------------------------------------------------- + + @Test + fun `pruneDedup evicts entries aged past the window and keeps fresh ones`() { + // Two stations marked at t=1000; one refreshed just before pruning. + assertThat(PskReporterSender.markIfFresh("OLD|14", 1_000L)).isTrue() + assertThat(PskReporterSender.markIfFresh("NEW|14", 1_000L)).isTrue() + assertThat(PskReporterSender.dedupSize()).isEqualTo(2) + + // Refresh NEW right at the prune instant so it is still inside the window. + val now = 1_000L + dedupWindowMs + assertThat(PskReporterSender.markIfFresh("NEW|14", now)).isTrue() + + // OLD is exactly window-old (nowMs - lastSeen == window) → aged out; NEW stays. + PskReporterSender.pruneDedup(now) + assertThat(PskReporterSender.dedupSize()).isEqualTo(1) + // OLD is gone, so it reports fresh again; NEW is still deduped. + assertThat(PskReporterSender.markIfFresh("OLD|14", now)).isTrue() + assertThat(PskReporterSender.markIfFresh("NEW|14", now)).isFalse() + } + + @Test + fun `pruneDedup keeps entries still inside the window`() { + assertThat(PskReporterSender.markIfFresh("W1AW|14", 1_000L)).isTrue() + // One millisecond short of the full window → not yet stale. + PskReporterSender.pruneDedup(1_000L + dedupWindowMs - 1) + assertThat(PskReporterSender.dedupSize()).isEqualTo(1) + } + + @Test + fun `pruneDedup on an empty map is a no-op`() { + PskReporterSender.pruneDedup(10_000_000L) + assertThat(PskReporterSender.dedupSize()).isEqualTo(0) + } + + @Test + fun `flush prunes stale dedup entries so the map stays bounded`() { + // Populate the dedup map with entries that are already stale relative to the + // real clock flush() uses, then flush an empty spot queue: without eviction + // the map would retain every historical entry forever (the leak). + assertThat(PskReporterSender.markIfFresh("STALE1|14", 1_000L)).isTrue() + assertThat(PskReporterSender.markIfFresh("STALE2|7", 2_000L)).isTrue() + assertThat(PskReporterSender.dedupSize()).isEqualTo(2) + + kotlinx.coroutines.runBlocking { PskReporterSender.flush() } + + // System.currentTimeMillis() is vastly larger than the fixture timestamps, so + // every entry has aged past the 5-minute window and flush() evicts them all. + assertThat(PskReporterSender.dedupSize()).isEqualTo(0) + } + /** Decode a hex string (all whitespace ignored) into bytes for golden-vector comparison. */ private fun hex(s: String): ByteArray { val clean = s.filterNot { it.isWhitespace() } From 3dc490b1e320107883216f2470f5ff9df7013549 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 24 Jul 2026 10:25:20 -0500 Subject: [PATCH 083/113] Add short/long-path beam heading for directional-antenna operators (#684) * Add short/long-path beam heading for directional-antenna operators Operators running a beam (or any directional antenna) need to know which way to point it. The QSO sheet already showed a single short-path azimuth; this adds the long-path (reciprocal) heading beside it and, optionally, a per-row heading in the decode list so you can aim the antenna before you even open a station. - New pure helper BeamHeading.kt: great-circle initial bearing, long-path reciprocal, degree normalisation, and grid-to-heading (null for missing/unparseable/identical grids). Trig is Android-free so it unit-tests without Robolectric. - QSO sheet: the Azimuth stat card now leads with the short-path heading and carries "LP " (long path) as a subtitle. Replaces the old private, short-path-only azimuth helper. - Decode row: opt-in beam-heading column next to distance, gated by a new "Show beam heading" toggle (Settings -> Decode filters, default off) so vertical/wire ops don't get an extra column they don't need. Tests: BeamHeadingTest (pure math) and BeamHeadingGridTest (grid decode + the decode-row wrapper). Co-Authored-By: Claude Opus 4.8 (1M context) * BeamHeading: correct greatCircleBearing KDoc range to [0, 360) The final % 360.0 means 360 is never returned (due north is 0), so the documented 0..360 was misleading. Doc-only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/GeneralVariables.java | 6 ++ .../com/k1af/ft8af/database/DatabaseOpr.java | 3 + .../ks3ckc/ft8af/ui/decode/BeamHeading.kt | 67 +++++++++++++ .../radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt | 21 +++++ .../radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt | 48 +++++----- .../ft8af/ui/settings/DecodeFilterSettings.kt | 14 +++ .../src/main/res/values/strings_compose.xml | 3 + .../ft8af/ui/decode/BeamHeadingGridTest.kt | 94 +++++++++++++++++++ .../ks3ckc/ft8af/ui/decode/BeamHeadingTest.kt | 83 ++++++++++++++++ 9 files changed, 314 insertions(+), 25 deletions(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/BeamHeading.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/BeamHeadingGridTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/BeamHeadingTest.kt diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 1c741932f..4ad9b541e 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -58,6 +58,12 @@ public class GeneralVariables { public static boolean distanceInMiles = true;//Display distances in miles (true) or kilometers (false) + // Show the great-circle beam heading (bearing to the station) next to the + // distance on each decode row, for operators pointing a directional antenna. + // Off by default — only useful with a beam, and it adds a column most ops + // (verticals/wires) don't need. + public static boolean showBeamHeading = false; + // Deep decode (subtract-and-redecode + extra LDPC iterations) is on by default so the app // pulls weak signals out from under strong ones the way WSJT-X does at its default depth. // A persisted "deepMode" config row still overrides this; only installs that never touched diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 8892f83e1..53129c6c6 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -3170,6 +3170,9 @@ protected Void doInBackground(Void... voids) { if (name.equalsIgnoreCase("distanceInMiles")) { GeneralVariables.distanceInMiles = !result.equals("0"); } + if (name.equalsIgnoreCase("showBeamHeading")) { + GeneralVariables.showBeamHeading = result.equals("1"); + } } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/BeamHeading.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/BeamHeading.kt new file mode 100644 index 000000000..04976ed7d --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/BeamHeading.kt @@ -0,0 +1,67 @@ +package radio.ks3ckc.ft8af.ui.decode + +import com.k1af.ft8af.maidenhead.MaidenheadGrid +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin + +/** + * Great-circle beam heading from the operator's grid to a remote station. + * + * [shortPathDeg] is the initial great-circle bearing the operator would point a + * directional antenna to take the short way round; [longPathDeg] is its + * reciprocal (the long way, over the antipode) — a genuinely different heading + * DX chasers use when the short path is closed. Both are integer degrees in + * `0..359`. + * + * The trig lives in [greatCircleBearing]/[longPathBearing] as pure functions (no + * Android types) so they unit-test without Robolectric; [computeBeamHeadings] + * decodes the grids through [MaidenheadGrid] and therefore needs it. + */ +internal data class BeamHeadings(val shortPathDeg: Int, val longPathDeg: Int) + +/** + * Initial great-circle bearing (degrees, `[0, 360)`) from point 1 to point 2 + * using the standard forward-azimuth formula. The final `% 360.0` means 360 is + * never returned (a due-north bearing is 0). Pure math — no Android types. + */ +internal fun greatCircleBearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val phi1 = Math.toRadians(lat1) + val phi2 = Math.toRadians(lat2) + val dLon = Math.toRadians(lon2 - lon1) + val y = sin(dLon) * cos(phi2) + val x = cos(phi1) * sin(phi2) - sin(phi1) * cos(phi2) * cos(dLon) + return (Math.toDegrees(atan2(y, x)) + 360.0) % 360.0 +} + +/** Long-path heading is the reciprocal of the short-path initial bearing. */ +internal fun longPathBearing(shortPathDeg: Double): Double = (shortPathDeg + 180.0) % 360.0 + +/** Round a bearing to whole degrees in `0..359` (360 wraps back to 0). */ +internal fun normalizeHeadingDeg(deg: Double): Int = (Math.round(deg).toInt() % 360 + 360) % 360 + +/** + * Short- and long-path beam headings from the operator's grid to the remote + * grid, or null when either grid is missing/unparseable or the two points are + * identical (a bearing to yourself is undefined). Delegates to + * [MaidenheadGrid.gridToLatLng] (Play-Services `LatLng`), so callers and tests + * that reach this need Robolectric. + */ +internal fun computeBeamHeadings(myGrid: String?, theirGrid: String?): BeamHeadings? { + if (myGrid.isNullOrEmpty() || theirGrid.isNullOrEmpty()) return null + return try { + val me = MaidenheadGrid.gridToLatLng(myGrid) ?: return null + val them = MaidenheadGrid.gridToLatLng(theirGrid) ?: return null + if (me.latitude == them.latitude && me.longitude == them.longitude) return null + val sp = greatCircleBearing(me.latitude, me.longitude, them.latitude, them.longitude) + BeamHeadings( + shortPathDeg = normalizeHeadingDeg(sp), + longPathDeg = normalizeHeadingDeg(longPathBearing(sp)), + ) + } catch (_: Exception) { + null + } +} + +/** Format a whole-degree heading as e.g. `47°`. */ +internal fun formatHeading(deg: Int): String = "$deg°" diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt index 8fe60ae4b..022783e15 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt @@ -227,6 +227,16 @@ fun DecodeRow( MetaText(distanceText) } + // Beam heading (short-path bearing) — opt-in, for beam operators + // who want to know which way to turn the antenna without opening + // the QSO sheet first. + if (GeneralVariables.showBeamHeading) { + val headingText = computeBeamHeadingText(message) + if (headingText.isNotEmpty()) { + MetaText(headingText) + } + } + Spacer(modifier = Modifier.weight(1f)) // Relative "ago" time @@ -429,3 +439,14 @@ private fun computeDistanceText(message: Ft8Message): String { "" } } + +/** + * Short-path beam heading (e.g. "47°") from the operator's grid to the message + * sender's grid, or "" when either grid is unknown. Shared with the QSO sheet + * via [computeBeamHeadings] so the row and the sheet never disagree. + */ +internal fun computeBeamHeadingText(message: Ft8Message): String { + val headings = computeBeamHeadings(GeneralVariables.getMyMaidenheadGrid(), message.maidenGrid) + ?: return "" + return formatHeading(headings.shortPathDeg) +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt index 61441b783..271d840aa 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt @@ -373,8 +373,15 @@ private fun StatCardsRow(message: Ft8Message) { val myGrid = GeneralVariables.getMyMaidenheadGrid() val theirGrid = message.maidenGrid ?: "" - // Compute azimuth + distance from the operator's grid to the remote station. - val azimuthText = computeAzimuthText(myGrid, theirGrid) + // Compute beam heading + distance from the operator's grid to the remote + // station. The azimuth card leads with the short-path heading and carries the + // long-path (reciprocal) heading as a subtitle, so DXers with a beam can see + // both ways to point the antenna. + val headings = computeBeamHeadings(myGrid, theirGrid) + val azimuthText = headings?.let { formatHeading(it.shortPathDeg) } ?: "--" + val longPathText = headings?.let { + stringResource(R.string.qso_stat_azimuth_long_path, formatHeading(it.longPathDeg)) + } val distanceText = computeDistanceText(myGrid, theirGrid) // Derive band label from message carrier frequency @@ -401,6 +408,7 @@ private fun StatCardsRow(message: Ft8Message) { StatCard( label = stringResource(R.string.qso_stat_azimuth), value = azimuthText, + subtitle = longPathText, modifier = Modifier.weight(1f), ) StatCard( @@ -416,6 +424,7 @@ private fun StatCard( label: String, value: String, modifier: Modifier = Modifier, + subtitle: String? = null, ) { GlassCard( modifier = modifier, @@ -445,6 +454,18 @@ private fun StatCard( maxLines = 1, softWrap = false, ) + if (subtitle != null) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = subtitle, + color = TextFaint, + fontFamily = GeistMonoFamily, + fontSize = 10.sp, + textAlign = TextAlign.Center, + maxLines = 1, + softWrap = false, + ) + } } } } @@ -920,29 +941,6 @@ private fun LastHeardRow(utcTimeMillis: Long, mainViewModel: MainViewModel) { // Helpers // --------------------------------------------------------------------------- -/** - * Compute the bearing/azimuth (in degrees) from the operator's grid to the - * remote station's grid. Returns "--" if either grid is unavailable. - */ -private fun computeAzimuthText(myGrid: String?, theirGrid: String?): String { - if (myGrid.isNullOrEmpty() || theirGrid.isNullOrEmpty()) return "--" - return try { - val myLatLng = MaidenheadGrid.gridToLatLng(myGrid) ?: return "--" - val theirLatLng = MaidenheadGrid.gridToLatLng(theirGrid) ?: return "--" - - val lat1 = Math.toRadians(myLatLng.latitude) - val lat2 = Math.toRadians(theirLatLng.latitude) - val dLon = Math.toRadians(theirLatLng.longitude - myLatLng.longitude) - - val y = Math.sin(dLon) * Math.cos(lat2) - val x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon) - val bearing = (Math.toDegrees(Math.atan2(y, x)) + 360) % 360 - "${String.format("%.0f", bearing)}\u00B0" - } catch (_: Exception) { - "--" - } -} - /** * Distance from the operator's grid to the remote station's grid, formatted in * the user's preferred unit (mi/km). Returns "--" when either grid is unknown. diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt index c50fd45ac..5983dbd0e 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt @@ -37,6 +37,7 @@ fun DecodeFilterSettings( var workedStationList by remember { mutableStateOf(GeneralVariables.getWorkedStationList()) } var highlightPota by remember { mutableStateOf(GeneralVariables.highlightPota) } var distanceInMiles by remember { mutableStateOf(GeneralVariables.distanceInMiles) } + var showBeamHeading by remember { mutableStateOf(GeneralVariables.showBeamHeading) } // Callsign blocklist (comma-separated entries) + decode display filters var blockedExact by remember { mutableStateOf(GeneralVariables.getBlockedExactCallsigns()) } @@ -375,6 +376,19 @@ fun DecodeFilterSettings( ) }, ) + SectionDivider() + SettingsRow( + label = stringResource(R.string.settings_show_beam_heading), + description = stringResource(R.string.settings_show_beam_heading_desc), + toggle = showBeamHeading, + onToggleChange = { checked -> + showBeamHeading = checked + GeneralVariables.showBeamHeading = checked + mainViewModel.databaseOpr.writeConfig( + "showBeamHeading", if (checked) "1" else "0", null, + ) + }, + ) } } } diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 0dfa3ed8c..4e8e7f0b6 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -206,6 +206,7 @@ Signal Distance Azimuth + LP %1$s Band QSO SEQUENCE Send call @@ -604,6 +605,8 @@ Only count QSOs made on the current mode Distance in Miles Show distances in miles instead of kilometers + Show beam heading + Add the great-circle bearing to each decode row, for pointing a directional antenna Exact Callsigns diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/BeamHeadingGridTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/BeamHeadingGridTest.kt new file mode 100644 index 000000000..6a93edb7d --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/BeamHeadingGridTest.kt @@ -0,0 +1,94 @@ +package radio.ks3ckc.ft8af.ui.decode + +import com.google.common.truth.Truth.assertThat +import com.k1af.ft8af.Ft8Message +import com.k1af.ft8af.GeneralVariables +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Unit tests for [computeBeamHeadings], which decodes both grids through + * [com.k1af.ft8af.maidenhead.MaidenheadGrid] (Play-Services `LatLng`) and so + * needs Robolectric. + */ +@RunWith(RobolectricTestRunner::class) +class BeamHeadingGridTest { + + @Test + fun `returns null when either grid is missing`() { + assertThat(computeBeamHeadings(null, "FN42")).isNull() + assertThat(computeBeamHeadings("FN42", null)).isNull() + assertThat(computeBeamHeadings("", "FN42")).isNull() + assertThat(computeBeamHeadings("FN42", "")).isNull() + } + + @Test + fun `returns null when a grid cannot be parsed`() { + // "ABC" is an unsupported length -> gridToLatLng returns null. + assertThat(computeBeamHeadings("FN42", "ABC")).isNull() + } + + @Test + fun `returns null for identical grids (bearing to yourself is undefined)`() { + assertThat(computeBeamHeadings("FN42", "FN42")).isNull() + } + + @Test + fun `long path is always the reciprocal of the short path`() { + val h = computeBeamHeadings("FN42", "IO91") + assertThat(h).isNotNull() + val expectedLp = (h!!.shortPathDeg + 180) % 360 + assertThat(h.longPathDeg).isEqualTo(expectedLp) + } + + @Test + fun `headings stay within 0 to 359`() { + val h = computeBeamHeadings("FN42", "JO65") + assertThat(h).isNotNull() + assertThat(h!!.shortPathDeg).isIn(0..359) + assertThat(h.longPathDeg).isIn(0..359) + } + + @Test + fun `Boston to London beams roughly north-east`() { + // Boston FN42 -> London IO91 is a classic north-easterly great-circle + // heading (~50 deg); allow slack for grid-centre quantisation. + val h = computeBeamHeadings("FN42", "IO91") + assertThat(h).isNotNull() + assertThat(h!!.shortPathDeg).isIn(30..70) + } + + @Test + fun `a station due east reads near 90 degrees`() { + // Two equatorial grids on the same field row: JJ00 (~0N,20E) -> + // KJ00 (~0N,40E) is due east. + val h = computeBeamHeadings("JJ00", "KJ00") + assertThat(h).isNotNull() + assertThat(h!!.shortPathDeg).isIn(80..100) + } + + // ---------- computeBeamHeadingText (decode-row wrapper) ---------- + + @Test + fun `row heading text is blank when the operator grid is unset`() { + GeneralVariables.setMyMaidenheadGrid("") + val msg = Ft8Message(0).apply { maidenGrid = "IO91" } + assertThat(computeBeamHeadingText(msg)).isEmpty() + } + + @Test + fun `row heading text is blank when the message has no grid`() { + GeneralVariables.setMyMaidenheadGrid("FN42") + val msg = Ft8Message(0).apply { maidenGrid = null } + assertThat(computeBeamHeadingText(msg)).isEmpty() + } + + @Test + fun `row heading text is the formatted short-path bearing`() { + GeneralVariables.setMyMaidenheadGrid("FN42") + val msg = Ft8Message(0).apply { maidenGrid = "IO91" } + val expected = formatHeading(computeBeamHeadings("FN42", "IO91")!!.shortPathDeg) + assertThat(computeBeamHeadingText(msg)).isEqualTo(expected) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/BeamHeadingTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/BeamHeadingTest.kt new file mode 100644 index 000000000..95f5ad8bc --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/BeamHeadingTest.kt @@ -0,0 +1,83 @@ +package radio.ks3ckc.ft8af.ui.decode + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for the pure beam-heading math ([greatCircleBearing], + * [longPathBearing], [normalizeHeadingDeg], [formatHeading]). No Android types + * are touched, so these run as plain JUnit (no Robolectric). Grid-decoding + * ([computeBeamHeadings]) is covered separately in [BeamHeadingGridTest]. + */ +class BeamHeadingTest { + + private val tol = 1e-6 + + // ---------- greatCircleBearing ---------- + + @Test + fun `due east along the equator is 90 degrees`() { + assertThat(greatCircleBearing(0.0, 0.0, 0.0, 10.0)).isWithin(tol).of(90.0) + } + + @Test + fun `due west along the equator is 270 degrees`() { + assertThat(greatCircleBearing(0.0, 0.0, 0.0, -10.0)).isWithin(tol).of(270.0) + } + + @Test + fun `heading due north along a meridian is 0 degrees`() { + assertThat(greatCircleBearing(10.0, 5.0, 40.0, 5.0)).isWithin(tol).of(0.0) + } + + @Test + fun `heading due south along a meridian is 180 degrees`() { + assertThat(greatCircleBearing(40.0, 5.0, 10.0, 5.0)).isWithin(tol).of(180.0) + } + + @Test + fun `bearing is always normalized into 0 to 360`() { + // A south-west leg would produce a negative raw atan2 result; it must be + // wrapped up into the [0,360) range rather than returned negative. + val b = greatCircleBearing(40.0, 5.0, 10.0, -20.0) + assertThat(b).isAtLeast(0.0) + assertThat(b).isLessThan(360.0) + assertThat(b).isGreaterThan(180.0) // south-and-west => third quadrant + } + + // ---------- longPathBearing ---------- + + @Test + fun `long path is the reciprocal of the short path`() { + assertThat(longPathBearing(47.0)).isWithin(tol).of(227.0) + assertThat(longPathBearing(0.0)).isWithin(tol).of(180.0) + } + + @Test + fun `long path wraps past 360 back into range`() { + // 270 + 180 = 450 -> 90 + assertThat(longPathBearing(270.0)).isWithin(tol).of(90.0) + } + + // ---------- normalizeHeadingDeg ---------- + + @Test + fun `rounding 359 point 7 wraps to 0 rather than 360`() { + assertThat(normalizeHeadingDeg(359.7)).isEqualTo(0) + } + + @Test + fun `rounding stays within 0 to 359`() { + assertThat(normalizeHeadingDeg(0.4)).isEqualTo(0) + assertThat(normalizeHeadingDeg(46.6)).isEqualTo(47) + assertThat(normalizeHeadingDeg(359.4)).isEqualTo(359) + } + + // ---------- formatHeading ---------- + + @Test + fun `format appends a degree sign`() { + assertThat(formatHeading(47)).isEqualTo("47°") + assertThat(formatHeading(0)).isEqualTo("0°") + } +} From 6c3fcca1cd31a4c467b8e16af737e5085623f746 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 24 Jul 2026 10:25:23 -0500 Subject: [PATCH 084/113] Add a "New State" decode highlight + filter for Worked All States chasers (#689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a "New State" decode highlight + filter for Worked All States chasers Surfaces stations calling from a US state the operator hasn't logged yet — the core of the WAS (Worked All States) award, one of the most-chased US awards. The decode-time unworked-state flag (Ft8Message.fromNewState) was already computed alongside fromDxcc/fromCq (US-grid -> state, US-only table) but only ever consumed by the "new state" alert; the decode list never showed it and there was no way to filter on it. - New "New State" filter chip: one-tap "who's calling from a state I still need" view (base.filter { checkIsCQ() && fromNewState }), mirroring "New DXCC" / "New Zone". - New NEW_STATE status pill (teal), shown when highlightNewState is on. Priority sits between NEW_ZONE and NEW_GRID — WAS is far more chased than a bare new grid field, so a new state outranks a new grid but still yields to the rarer new CQ zone (and the headline new DXCC). - Settings -> Decode Highlights toggle (highlightNewState, persisted via writeConfig / loaded in DatabaseOpr). Defaults off (like New Grid): the flag is US-only, so leaving it on would flood non-US operators with pills. The filter chip is available to everyone regardless of the toggle. No decoder or transmit code paths change. Tests: NewStateTest (pill + priority vs NEW_ZONE/NEW_GRID), DecodeFilterTest (filter cases), DecodeScreenTest (empty-state copy). Co-Authored-By: Claude Opus 4.8 (1M context) * NewStateTest: drop unnecessary Robolectric runner (pure-JVM test) The Ft8Message 3-arg constructor only uppercases its fields and the resolveQsoStatus path reads plain GeneralVariables state — no Android framework is touched. Verified the 5 tests pass under the plain JVM runner; dropping Robolectric removes its per-class startup overhead. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/GeneralVariables.java | 1 + .../com/k1af/ft8af/database/DatabaseOpr.java | 3 + .../kotlin/radio/ks3ckc/ft8af/theme/Color.kt | 3 + .../ks3ckc/ft8af/ui/components/StatusPill.kt | 6 + .../radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt | 6 + .../ks3ckc/ft8af/ui/decode/DecodeScreen.kt | 9 +- .../ft8af/ui/settings/DecodeFilterSettings.kt | 14 +++ .../src/main/res/values/strings_compose.xml | 6 + .../ft8af/ui/decode/DecodeFilterTest.kt | 22 ++++ .../ft8af/ui/decode/DecodeScreenTest.kt | 11 ++ .../ks3ckc/ft8af/ui/decode/NewStateTest.kt | 112 ++++++++++++++++++ 11 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/NewStateTest.kt diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 4ad9b541e..1685a8f1e 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -586,6 +586,7 @@ public static String excludedBandsToCsv() { // status pill shown for each worked-before category in resolveQsoStatus(). public static boolean highlightNewDxcc = true;//Highlight stations from an unworked DXCC entity public static boolean highlightNewZone = true;//Highlight stations from an unworked CQ zone (Worked All Zones) + public static boolean highlightNewState = false;//Off by default — US-only (Worked All States); noise for non-US ops public static boolean highlightNewGrid = false;//Off by default — most grids are "new", so it's noisy public static boolean highlightNewBand = true;//Highlight stations worked only on other bands public static boolean highlightWorked = true;//Master enable for worked-station handling (see workedStationMode) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 53129c6c6..b9fa037bf 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -3142,6 +3142,9 @@ protected Void doInBackground(Void... voids) { if (name.equalsIgnoreCase("highlightNewZone")) { GeneralVariables.highlightNewZone = result.equals("1"); } + if (name.equalsIgnoreCase("highlightNewState")) { + GeneralVariables.highlightNewState = result.equals("1"); + } if (name.equalsIgnoreCase("highlightNewGrid")) { GeneralVariables.highlightNewGrid = result.equals("1"); } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/theme/Color.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/theme/Color.kt index 056c464ce..c80841cae 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/theme/Color.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/theme/Color.kt @@ -160,6 +160,9 @@ val StatusWarn = Color(0xFFFACC15) // Sky-blue, distinct from the purple NEW-DXCC and cyan NEW-BAND pills, for the // NEW-ZONE (Worked All Zones) badge. val StatusZone = Color(0xFF60A5FA) +// Teal, distinct from the blue NEW-ZONE, cyan NEW-BAND and green POTA pills, for +// the NEW-STATE (Worked All States) badge. +val StatusState = Color(0xFF2DD4BF) val StatusBad = Color(0xFFEF4444) // Band colors (for donut chart / logbook) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/StatusPill.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/StatusPill.kt index 00e0a4d04..d600b045b 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/StatusPill.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/StatusPill.kt @@ -42,6 +42,12 @@ enum class QsoStatus( Color(0x1F60A5FA), // rgba(96,165,250,0.12) Color(0x4760A5FA), // rgba(96,165,250,0.28) ), + NEW_STATE( + R.string.status_new_state, + StatusState, + Color(0x1F2DD4BF), // rgba(45,212,191,0.12) + Color(0x472DD4BF), // rgba(45,212,191,0.28) + ), NEW_GRID( R.string.status_new_grid, StatusWarn, diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt index 022783e15..b28a4a912 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt @@ -415,6 +415,12 @@ internal fun resolveQsoStatus(message: Ft8Message): QsoStatus? { // is set at decode time in CallsignDatabase (unworked-zone lookup) and // only ever true once the zone map is ready — same gating as fromDxcc. GeneralVariables.highlightNewZone && message.fromCq -> QsoStatus.NEW_ZONE + // A new US state (Worked All States) outranks a new grid: WAS is one of + // the most-chased US awards, so an unworked state is more prized than a + // bare new grid field. message.fromNewState is set at decode time in + // CallsignDatabase (US-grid → unworked-state lookup); null/non-US grids + // leave it false. + GeneralVariables.highlightNewState && message.fromNewState -> QsoStatus.NEW_STATE GeneralVariables.highlightNewGrid && newGrid -> QsoStatus.NEW_GRID GeneralVariables.highlightNewBand && newBand -> QsoStatus.NEW_BAND effectiveWorkedMode() == WorkedStationMode.HIGHLIGHT && diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt index f60458b5a..7ae3829bf 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt @@ -67,7 +67,7 @@ fun DecodeScreen( // Filter state. Backed by the ViewModel so the chosen filter survives // navigation away from Decode and back (the screen is recreated by the // tab switch, which would otherwise reset a local rememberSaveable). - val filterOptions = listOf("All", "CQ Calls", "CQ POTA", "New DXCC", "New Zone", "New Grid", "Needed", "For Me") + val filterOptions = listOf("All", "CQ Calls", "CQ POTA", "New DXCC", "New Zone", "New State", "New Grid", "Needed", "For Me") val selectedFilter by mainViewModel.decodeFilter.observeAsState("All") // Couple the "CQ POTA" display filter to Hunt: while it's selected, the auto-call @@ -523,6 +523,7 @@ private fun filterLabel(key: String): String = when (key) { "CQ POTA" -> stringResource(R.string.decode_filter_cq_pota) "New DXCC" -> stringResource(R.string.decode_filter_new_dxcc) "New Zone" -> stringResource(R.string.decode_filter_new_zone) + "New State" -> stringResource(R.string.decode_filter_new_state) "New Grid" -> stringResource(R.string.decode_filter_new_grid) "Needed" -> stringResource(R.string.decode_filter_needed) "For Me" -> stringResource(R.string.decode_filter_for_me) @@ -541,6 +542,7 @@ private fun filterLabel(key: String): String = when (key) { * - CQ Calls: only CQ messages * - New DXCC: CQ from a DXCC entity not yet in the operator's worked list * - New Zone: CQ from a CQ zone not yet in the operator's worked list (WAZ) + * - New State: CQ from a US state not yet in the operator's worked list (WAS) * - New Grid: CQ from a Maidenhead grid field not yet in the operator's worked list * - Needed: need QSL confirmation (not in QSL callsign list) * - For Me: callsignTo matches operator's callsign @@ -594,6 +596,10 @@ internal fun filterMessages( // stations from a CQ zone the operator hasn't logged yet. fromCq is the // decode-time unworked-zone flag, computed alongside fromDxcc. "New Zone" -> base.filter { it.checkIsCQ() && it.fromCq } + // Mirror of "New DXCC" for state chasers (Worked All States): only CQ + // stations from a US state the operator hasn't logged yet. fromNewState is + // the decode-time unworked-state flag (US-grid → state, US-only table). + "New State" -> base.filter { it.checkIsCQ() && it.fromNewState } // Mirror of "New DXCC" for grid chasers (VUCC / grid hunting): only CQ // stations whose grid field the operator hasn't logged yet, so the list // becomes a one-tap "who's calling from a grid I still need" view. @@ -623,6 +629,7 @@ internal fun EmptyState( "CQ POTA" -> stringResource(R.string.decode_empty_pota_title) to stringResource(R.string.decode_empty_pota_body) "New DXCC" -> stringResource(R.string.decode_empty_dxcc_title) to stringResource(R.string.decode_empty_dxcc_body) "New Zone" -> stringResource(R.string.decode_empty_zone_title) to stringResource(R.string.decode_empty_zone_body) + "New State" -> stringResource(R.string.decode_empty_state_title) to stringResource(R.string.decode_empty_state_body) "New Grid" -> stringResource(R.string.decode_empty_grid_title) to stringResource(R.string.decode_empty_grid_body) "Needed" -> stringResource(R.string.decode_empty_needed_title) to stringResource(R.string.decode_empty_needed_body) "For Me" -> stringResource(R.string.decode_empty_forme_title) to stringResource(R.string.decode_empty_forme_body) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt index 5983dbd0e..0138aa431 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt @@ -28,6 +28,7 @@ fun DecodeFilterSettings( // Decode-list highlight toggles var highlightNewDxcc by remember { mutableStateOf(GeneralVariables.highlightNewDxcc) } var highlightNewZone by remember { mutableStateOf(GeneralVariables.highlightNewZone) } + var highlightNewState by remember { mutableStateOf(GeneralVariables.highlightNewState) } var highlightNewGrid by remember { mutableStateOf(GeneralVariables.highlightNewGrid) } var highlightNewBand by remember { mutableStateOf(GeneralVariables.highlightNewBand) } var highlightWorked by remember { mutableStateOf(GeneralVariables.highlightWorked) } @@ -259,6 +260,19 @@ fun DecodeFilterSettings( }, ) SectionDivider() + SettingsRow( + label = stringResource(R.string.settings_highlight_new_state), + description = stringResource(R.string.settings_highlight_new_state_desc), + toggle = highlightNewState, + onToggleChange = { checked -> + highlightNewState = checked + GeneralVariables.highlightNewState = checked + mainViewModel.databaseOpr.writeConfig( + "highlightNewState", if (checked) "1" else "0", null, + ) + }, + ) + SectionDivider() SettingsRow( label = stringResource(R.string.settings_highlight_new_grid), description = stringResource(R.string.settings_highlight_new_grid_desc), diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 4e8e7f0b6..60afcc43a 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -47,6 +47,7 @@ NEW DXCC NEW ZONE + NEW STATE NEW GRID NEW BAND POTA @@ -176,6 +177,7 @@ CQ POTA New DXCC New Zone + New State New Grid Needed For Me @@ -187,6 +189,8 @@ No unworked DXCC entities have been decoded yet. No new zones No stations calling from an unworked CQ zone have been decoded yet. + No new states + No stations calling from an unworked US state have been decoded yet. No new grids No stations calling from an unworked grid square have been decoded yet. Nothing needed @@ -580,6 +584,8 @@ Highlight stations from an unworked DXCC entity New Zone Highlight not-yet-worked CQ zones (Worked All Zones) + New State + Highlight not-yet-worked US states (Worked All States) New Grid Highlight not-yet-worked Maidenhead grids New Band diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeFilterTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeFilterTest.kt index 9d320a49b..a747c0255 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeFilterTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeFilterTest.kt @@ -57,6 +57,28 @@ class DecodeFilterTest { assertThat(result).containsExactly(cqMsg) } + @Test + fun newState_keepsCqFromUnworkedState() { + // fromNewState is the decode-time "unworked US state" flag; the filter + // keeps only CQ stations that carry it. + val fresh = cq("K1ABC").apply { fromNewState = true } + val worked = cq("K2DEF").apply { fromNewState = false } + + val result = filterMessages(listOf(fresh, worked), "New State") + + assertThat(result).containsExactly(fresh) + } + + @Test + fun newState_dropsDirectedMessages() { + // A directed reply isn't a callable CQ even if it's from a new state. + val directedNewState = directed("W1AW", "K2DEF").apply { fromNewState = true } + + val result = filterMessages(listOf(directedNewState), "New State") + + assertThat(result).isEmpty() + } + @Test fun newGrid_keepsCqFromUnworkedGrid() { // No grids worked yet, so every 4-char grid counts as new. diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreenTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreenTest.kt index 035c818f8..bdc2068f5 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreenTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreenTest.kt @@ -66,6 +66,17 @@ class DecodeScreenTest { composeRule.onNodeWithText(title).assertIsDisplayed() } + @Test + fun newStateFilter_showsStateEmptyCopy() { + val title = context.getString(R.string.decode_empty_state_title) + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + EmptyState(selectedFilter = "New State") + } + + composeRule.onNodeWithText(title).assertIsDisplayed() + } + @Test fun newGridFilter_showsGridEmptyCopy() { val title = context.getString(R.string.decode_empty_grid_title) diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/NewStateTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/NewStateTest.kt new file mode 100644 index 000000000..70ad73423 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/NewStateTest.kt @@ -0,0 +1,112 @@ +package radio.ks3ckc.ft8af.ui.decode + +import com.k1af.ft8af.Ft8Message +import com.k1af.ft8af.GeneralVariables +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import radio.ks3ckc.ft8af.ui.components.QsoStatus + +/** + * Unit-tests the NEW_STATE (Worked All States) decode-row pill wired off the + * decode-time [Ft8Message.fromNewState] flag, plus its priority relative to the + * neighbouring NEW_ZONE and NEW_GRID badges. The filter side is covered in + * [DecodeFilterTest]; this keeps the two in agreement on what counts as an + * unworked US state. + * + * Pure JVM — the [Ft8Message] 3-arg constructor only uppercases its fields and + * the [resolveQsoStatus] path reads plain [GeneralVariables] state, so no + * Android framework is touched and no Robolectric runner is needed. + */ +class NewStateTest { + + private var savedHighlightPota = false + private var savedHighlightNewDxcc = false + private var savedHighlightNewZone = false + private var savedHighlightNewState = false + private var savedHighlightNewGrid = false + private var savedHighlightNewBand = false + private var savedHighlightWorked = false + + @Before + fun setUp() { + savedHighlightPota = GeneralVariables.highlightPota + savedHighlightNewDxcc = GeneralVariables.highlightNewDxcc + savedHighlightNewZone = GeneralVariables.highlightNewZone + savedHighlightNewState = GeneralVariables.highlightNewState + savedHighlightNewGrid = GeneralVariables.highlightNewGrid + savedHighlightNewBand = GeneralVariables.highlightNewBand + savedHighlightWorked = GeneralVariables.highlightWorked + GeneralVariables.QSL_Grid_list.clear() + GeneralVariables.QSL_Callsign_list.clear() + // Isolate the branch under test: only the state highlight is on unless a + // test opts another in. + GeneralVariables.highlightPota = false + GeneralVariables.highlightNewDxcc = false + GeneralVariables.highlightNewZone = false + GeneralVariables.highlightNewState = true + GeneralVariables.highlightNewGrid = false + GeneralVariables.highlightNewBand = false + GeneralVariables.highlightWorked = false + } + + @After + fun tearDown() { + GeneralVariables.highlightPota = savedHighlightPota + GeneralVariables.highlightNewDxcc = savedHighlightNewDxcc + GeneralVariables.highlightNewZone = savedHighlightNewZone + GeneralVariables.highlightNewState = savedHighlightNewState + GeneralVariables.highlightNewGrid = savedHighlightNewGrid + GeneralVariables.highlightNewBand = savedHighlightNewBand + GeneralVariables.highlightWorked = savedHighlightWorked + GeneralVariables.QSL_Grid_list.clear() + GeneralVariables.QSL_Callsign_list.clear() + } + + private fun cq(from: String): Ft8Message = Ft8Message("CQ", from, "TEST") + + @Test + fun newStatePill_surfacesWhenHighlightEnabledAndFromNewState() { + val msg = cq("K1ABC").apply { fromNewState = true } + assertThat(resolveQsoStatus(msg)).isEqualTo(QsoStatus.NEW_STATE) + } + + @Test + fun newStatePill_hiddenWhenHighlightDisabled() { + GeneralVariables.highlightNewState = false + val msg = cq("K1ABC").apply { fromNewState = true } + // Falls through to the plain CQ badge instead of NEW_STATE. + assertThat(resolveQsoStatus(msg)).isEqualTo(QsoStatus.CQ) + } + + @Test + fun newStatePill_hiddenWhenStateAlreadyWorked() { + val msg = cq("K1ABC").apply { fromNewState = false } + assertThat(resolveQsoStatus(msg)).isEqualTo(QsoStatus.CQ) + } + + @Test + fun newZone_outranksNewState() { + // Only 40 CQ zones exist, so an unworked one is rarer than an unworked + // state — NEW_ZONE wins when both apply. + GeneralVariables.highlightNewZone = true + val msg = cq("K1ABC").apply { + fromCq = true + fromNewState = true + } + assertThat(resolveQsoStatus(msg)).isEqualTo(QsoStatus.NEW_ZONE) + } + + @Test + fun newState_outranksNewGrid() { + // WAS is far more chased than a bare new grid field, so NEW_STATE wins + // when a station is both a new state and a new grid. + GeneralVariables.highlightNewGrid = true + val msg = cq("K1ABC").apply { + fromNewState = true + maidenGrid = "FN42" // unworked grid (QSL_Grid_list is empty) + } + assertThat(resolveQsoStatus(msg)).isEqualTo(QsoStatus.NEW_STATE) + } +} From 794291879f4c21b3352abaf4ddee1c42eeced2bd Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 24 Jul 2026 10:25:27 -0500 Subject: [PATCH 085/113] Fix DX/watchlist alert notification showing "-2147483648 dB" when the decoder gives no SNR (#691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix DX/watchlist/new-DXCC/new-state alert body showing '-2147483648 dB' when SNR is unknown DxAlertNotifier.defaultBody appended msg.snr unconditionally. A decoded message can be valid yet carry no SNR — FT8SignalListener logs "SNR not set by decoder" and still adds the candidate to the decode list, and that list is what feeds processDecodes(). When such a station is a new DXCC/state, a CQ reply target, or on the watchlist, the notification body rendered the SNR_UNKNOWN sentinel (Integer.MIN_VALUE) as "-2147483648 dB". The sibling cqReplyBody already guards this exact case; mirror it in defaultBody so the SNR line is dropped when unknown. Made both helpers package-private static and added DxAlertBodyTest (pure JVM) covering the unknown/known-SNR and missing-grid permutations. Co-Authored-By: Claude Opus 4.8 (1M context) * DxAlertBodyTest: fix misleading test name and slash-joined @link Javadoc Rename defaultBody_gridOnlyWhenSnrUnknownAndGridMissing -> _callsignOnlyWhenSnrUnknownAndGridMissing (it asserts callsign-only output, no grid), and rewrite the class Javadoc to link defaultBody and cqReplyBody in a normal sentence instead of '{@link}/{@link}'. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/alert/DxAlertNotifier.java | 13 +++- .../com/k1af/ft8af/alert/DxAlertBodyTest.java | 62 +++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/alert/DxAlertBodyTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java b/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java index e40472adf..5b765deef 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java @@ -168,16 +168,23 @@ public void notifyQsoComplete(QSLRecord qslRecord) { body.toString(), call, qslRecord.getBandFreq()); } - private static String defaultBody(Ft8Message msg) { + static String defaultBody(Ft8Message msg) { StringBuilder body = new StringBuilder(msg.getCallsignFrom()); if (msg.maidenGrid != null && !msg.maidenGrid.isEmpty()) { body.append(" ").append(msg.maidenGrid); } - body.append(" ").append(msg.snr).append(" dB"); + // The decoder can emit a valid message with no SNR (FT8SignalListener logs + // "SNR not set by decoder" and still adds it to the decode list), so snr may be + // the SNR_UNKNOWN sentinel (Integer.MIN_VALUE). Appending it unconditionally + // rendered "-2147483648 dB" in the notification body — mirror cqReplyBody and + // drop the SNR line when it is unknown. + if (msg.snr != Ft8Message.SNR_UNKNOWN) { + body.append(" ").append(msg.snr).append(" dB"); + } return body.toString(); } - private static String cqReplyBody(Ft8Message msg) { + static String cqReplyBody(Ft8Message msg) { StringBuilder body = new StringBuilder(msg.getMessageText()); if (msg.snr != Ft8Message.SNR_UNKNOWN) { body.append(" ").append(msg.snr).append(" dB"); diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/alert/DxAlertBodyTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/alert/DxAlertBodyTest.java new file mode 100644 index 000000000..77683d35d --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/alert/DxAlertBodyTest.java @@ -0,0 +1,62 @@ +package com.k1af.ft8af.alert; + +import static com.google.common.truth.Truth.assertThat; + +import com.k1af.ft8af.Ft8Message; + +import org.junit.Test; + +/** + * Pure-JVM tests for the {@link DxAlertNotifier#defaultBody} and + * {@link DxAlertNotifier#cqReplyBody} notification-body formatting. The interesting + * case is an unknown SNR: a valid decode can + * reach the alert path with {@code snr == Ft8Message.SNR_UNKNOWN} (the decoder does not always + * set it — FT8SignalListener logs "SNR not set by decoder" and still lists the message), and the + * DX/watchlist/New-DXCC/New-State body must not render the {@link Integer#MIN_VALUE} sentinel. + */ +public class DxAlertBodyTest { + + private static Ft8Message msg(String from, String grid, int snr) { + Ft8Message m = new Ft8Message(0); + m.callsignFrom = from; + m.maidenGrid = grid; + m.snr = snr; + return m; + } + + @Test + public void defaultBody_includesSnrWhenKnown() { + String body = DxAlertNotifier.defaultBody(msg("JA1ABC", "PM95", -12)); + assertThat(body).isEqualTo("JA1ABC PM95 -12 dB"); + } + + @Test + public void defaultBody_omitsSnrWhenUnknown() { + String body = DxAlertNotifier.defaultBody(msg("JA1ABC", "PM95", Ft8Message.SNR_UNKNOWN)); + // Before the fix this appended "-2147483648 dB"; the sentinel must never surface. + assertThat(body).isEqualTo("JA1ABC PM95"); + assertThat(body).doesNotContain("dB"); + assertThat(body).doesNotContain(String.valueOf(Integer.MIN_VALUE)); + } + + @Test + public void defaultBody_omitsGridWhenEmpty() { + assertThat(DxAlertNotifier.defaultBody(msg("JA1ABC", "", 3))).isEqualTo("JA1ABC 3 dB"); + assertThat(DxAlertNotifier.defaultBody(msg("JA1ABC", null, 3))).isEqualTo("JA1ABC 3 dB"); + } + + @Test + public void defaultBody_callsignOnlyWhenSnrUnknownAndGridMissing() { + assertThat(DxAlertNotifier.defaultBody(msg("JA1ABC", null, Ft8Message.SNR_UNKNOWN))) + .isEqualTo("JA1ABC"); + } + + @Test + public void cqReplyBody_alsoOmitsUnknownSnr_forParity() { + Ft8Message m = new Ft8Message(0, 0, "MYCALL", "JA1ABC", "RR73"); + m.snr = Ft8Message.SNR_UNKNOWN; + String body = DxAlertNotifier.cqReplyBody(m); + assertThat(body).doesNotContain("dB"); + assertThat(body).doesNotContain(String.valueOf(Integer.MIN_VALUE)); + } +} From a27eee85610a25ee26aea028a9c4dbe26a258f29 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 24 Jul 2026 10:26:01 -0500 Subject: [PATCH 086/113] Make Worked All States (WAS) award real, with the states still needed (#666) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make Worked All States (WAS) award real, with the states still needed The Logbook's WAS award was a placeholder: AwardsTab derived its worked count from DXCC entities (dxccEntities * 50 / 340), so the number shown had nothing to do with which US states the operator had actually contacted. FT8AF already resolves a QSO's US state from its Maidenhead grid (UsStateLookup), so real WAS progress can be computed straight from the logbook. This adds a pure WorkedAllStates helper (workedStates / neededStates / neededStatesPreview, with DC excluded) and wires it in: - Stats tab gains a real Worked All States progress bar (x / 50). - Awards tab's WAS card now shows the true worked count and, below the bar, a capped preview of the states still needed -- the at-a-glance list a WAS chaser actually wants. Chasing WAS is one of the most popular FT8 activities for the large US operator base, so an honest, actionable WAS tracker is a noticeable win. Co-Authored-By: Claude Opus 4.8 (1M context) * WAS: normalize grid with Locale.ROOT before US-state lookup Uppercasing the grid with the default locale could mis-resolve under the Turkish locale (lower-case 'i' -> dotted 'İ'), missing the ASCII keys. Normalize at the lookup source so every caller is locale-safe, and add a Turkish-locale regression test. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../ks3ckc/ft8af/ui/decode/UsStateLookup.kt | 3 +- .../ks3ckc/ft8af/ui/logbook/LogbookScreen.kt | 93 ++++++++++- .../ft8af/ui/logbook/WorkedAllStates.kt | 72 +++++++++ .../src/main/res/values/strings_compose.xml | 2 + .../ft8af/ui/decode/UsStateLookupTest.kt | 16 ++ .../ft8af/ui/logbook/WorkedAllStatesTest.kt | 149 ++++++++++++++++++ 6 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/WorkedAllStates.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/WorkedAllStatesTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/UsStateLookup.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/UsStateLookup.kt index a47df3f36..b50bc26fc 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/UsStateLookup.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/UsStateLookup.kt @@ -2,6 +2,7 @@ package radio.ks3ckc.ft8af.ui.decode import android.content.Context import org.json.JSONObject +import java.util.Locale internal object UsStateLookup { @Volatile private var map: Map? = null @@ -11,7 +12,7 @@ internal object UsStateLookup { val m = map ?: synchronized(this) { map ?: load(context).also { map = it } } - return m[grid.take(4).uppercase()] + return m[grid.take(4).uppercase(Locale.ROOT)] } private fun load(context: Context): Map { diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt index 5844352c8..ce4ba4772 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt @@ -139,6 +139,11 @@ private data class LogbookStats( val cqZones: Int = 0, val ituZones: Int = 0, val bandCounts: List> = emptyList(), + // Worked All States (WAS): distinct US states worked (0..50) and the + // canonically-ordered list of states still needed. Derived from each QSO's + // grid via UsStateLookup — see WorkedAllStates.kt. + val statesWorked: Int = 0, + val neededStates: List = emptyList(), ) private data class AwardProgress( @@ -147,6 +152,10 @@ private data class AwardProgress( val current: Int, val total: Int, val color: Color, + // When non-empty and the award is incomplete, the remaining targets are + // surfaced as chips under the progress bar (used by WAS to show the states + // still needed). Empty for awards without an enumerable "needed" list. + val remaining: List = emptyList(), ) // --------------------------------------------------------------------------- @@ -174,6 +183,9 @@ fun LogbookScreen(mainViewModel: MainViewModel) { var syncDialogState by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + // Captured for off-thread grid->state resolution (WAS). UsStateLookup caches + // the asset table, so calling it from the IO loader below is cheap. + val appContext = LocalContext.current.applicationContext // Load records and stats from the database. Re-runs when refreshKey changes // (e.g. after the user edits or deletes a QSO). @@ -246,12 +258,20 @@ fun LogbookScreen(mainViewModel: MainViewModel) { val bandCounts = bandInfo?.values?.map { (it.name ?: "") to it.value } ?: emptyList() + // Worked All States: resolve each QSO's grid to a US state and + // collapse to the distinct set (see WorkedAllStates.kt). + val worked = workedStates(loaded.map { it.grid }) { + UsStateLookup.stateFromGrid(appContext, it) + } + stats = LogbookStats( totalQsos = totalQsos, dxccEntities = dxccCount, cqZones = cqCount, ituZones = ituCount, bandCounts = bandCounts, + statesWorked = worked.size, + neededStates = neededStates(worked), ) } catch (_: Exception) { // Leave records/stats at defaults on error @@ -673,6 +693,13 @@ private fun StatsTab(stats: LogbookStats, records: List) { gradientColors = listOf(Signal, StatusConfirmed), progress = chartProgress, ) + AwardProgressBar( + label = stringResource(R.string.log_award_was_states), + current = stats.statesWorked, + total = 50, + gradientColors = listOf(Accent, StatusConfirmed), + progress = chartProgress, + ) AwardProgressBar( label = stringResource(R.string.log_award_vucc_grid_squares), current = gridSquaresWorked(records), @@ -1711,9 +1738,10 @@ private fun AwardsTab(stats: LogbookStats) { AwardProgress( name = wasName, description = wasDesc, - current = (stats.dxccEntities * 50 / 340.coerceAtLeast(1)).coerceAtMost(50), + current = stats.statesWorked, total = 50, color = Accent, + remaining = stats.neededStates, ), AwardProgress( name = wazName, @@ -1844,11 +1872,74 @@ private fun AwardCard(award: AwardProgress) { ), ) } + + // States still needed (WAS): a capped preview of remaining + // targets so a chaser can see at a glance what's left to work. + if (award.remaining.isNotEmpty()) { + Spacer(modifier = Modifier.height(10.dp)) + NeededStatesRow(remaining = award.remaining, accent = award.color) + } } } } } +// --------------------------------------------------------------------------- +// Needed-states chip row (WAS award card) +// --------------------------------------------------------------------------- + +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +@Composable +private fun NeededStatesRow(remaining: List, accent: Color) { + // Cap the visible chips so a fresh log (up to 50 remaining) never floods the + // card; the rest collapse into a trailing "+N" chip. + val (shown, overflow) = neededStatesPreview(remaining, NEEDED_STATES_PREVIEW_MAX) + Text( + text = stringResource(R.string.log_award_states_needed), + color = TextFaint, + fontSize = 9.5.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.06.sp, + ) + Spacer(modifier = Modifier.height(5.dp)) + androidx.compose.foundation.layout.FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + for (state in shown) { + StateChip(text = state, accent = accent) + } + if (overflow > 0) { + StateChip(text = "+$overflow", accent = accent) + } + } +} + +@Composable +private fun StateChip(text: String, accent: Color) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(accent.copy(alpha = 0.12f)) + .border(1.dp, accent.copy(alpha = 0.26f), RoundedCornerShape(4.dp)) + .padding(horizontal = 5.dp, vertical = 2.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + color = accent, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + maxLines = 1, + ) + } +} + +/** Max needed-state chips shown before collapsing the rest into a "+N" chip. */ +private const val NEEDED_STATES_PREVIEW_MAX = 16 + // =========================================================================== // Per-row QSO edit / delete dialogs (Recent tab) // =========================================================================== diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/WorkedAllStates.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/WorkedAllStates.kt new file mode 100644 index 000000000..e0b83ae52 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/WorkedAllStates.kt @@ -0,0 +1,72 @@ +package radio.ks3ckc.ft8af.ui.logbook + +import java.util.Locale + +/** + * Worked All States (WAS) award logic. + * + * ARRL's WAS award is earned by making contact with all 50 US states. FT8AF + * already derives a QSO's state from its Maidenhead grid (see + * [radio.ks3ckc.ft8af.ui.decode.UsStateLookup]), so both the count of distinct + * worked states and the list of states still needed can be computed directly + * from the logbook — no separate tracking table required. + * + * The functions here are pure (grid->state resolution is injected as a lambda) + * so they unit-test without an Android context, mirroring [workedGridFields] / + * [buildGridHeatmapCells] in the same package. + * + * Washington DC is intentionally excluded: it is not one of the 50 WAS states, + * even though the grid->state table can resolve a grid to "DC". + */ + +/** The 50 US states (two-letter USPS abbreviations), the ARRL WAS award basis. */ +internal val WAS_STATES: List = listOf( + "AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", + "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD", + "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", + "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", + "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY", +) + +private val WAS_STATE_SET: Set = WAS_STATES.toSet() + +/** + * The distinct WAS states worked, derived from each grid via [gridToState]. + * + * A grid that resolves to no state (or to DC, or to any non-WAS designator) is + * ignored. Resolved abbreviations are trimmed and upper-cased with + * [Locale.ROOT] — the state table's designators are ASCII, so upper-casing must + * be locale-insensitive (a Turkish-locale `uppercase()` would map "il" to "İL" + * and never match the ASCII "IL" state). + */ +internal fun workedStates( + grids: List, + gridToState: (String) -> String?, +): Set = + grids.asSequence() + .filterNotNull() + .mapNotNull { grid -> gridToState(grid)?.trim()?.uppercase(Locale.ROOT) } + .filter { it in WAS_STATE_SET } + .toSet() + +/** + * The WAS states still needed, in the canonical [WAS_STATES] order, given the + * set already [worked]. Comparison is case-insensitive; anything in [worked] + * that is not a WAS state (e.g. "DC") is ignored. + */ +internal fun neededStates(worked: Set): List { + val have = worked.mapTo(HashSet()) { it.trim().uppercase(Locale.ROOT) } + return WAS_STATES.filter { it !in have } +} + +/** + * Split a [remaining] states list into the chips to display (at most [max]) and + * the count hidden behind a trailing "+N" overflow chip, so the WAS card can + * preview the states still needed without ever rendering all 50. [max] <= 0 + * shows nothing and reports the whole list as overflow. + */ +internal fun neededStatesPreview(remaining: List, max: Int): Pair, Int> = when { + max <= 0 -> emptyList() to remaining.size + remaining.size <= max -> remaining to 0 + else -> remaining.take(max) to (remaining.size - max) +} diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 60afcc43a..61d3259b2 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -256,6 +256,8 @@ DXCC Mixed VUCC Grid Squares DXCC Challenge + Worked All States + STATES NEEDED Best DX Grid Coverage Signal Trend diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/UsStateLookupTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/UsStateLookupTest.kt index 4c90dc6c5..8b1acee65 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/UsStateLookupTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/UsStateLookupTest.kt @@ -1,6 +1,7 @@ package radio.ks3ckc.ft8af.ui.decode import com.google.common.truth.Truth.assertThat +import java.util.Locale import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -65,4 +66,19 @@ class UsStateLookupTest { // A syntactically valid grid that is not in the US table. assertThat(UsStateLookup.stateFromGrid(context, "ZZ99")).isNull() } + + @Test + fun stateFromGrid_isLocaleIndependent_underTurkishDefault() { + // The grid is uppercased before lookup. Under the Turkish locale a + // lower-case 'i' uppercases to the dotted 'İ' (U+0130), which would miss + // the ASCII keys — so the lookup must normalize with Locale.ROOT. Guard + // that a lower-case grid still resolves regardless of the default locale. + val previous = Locale.getDefault() + try { + Locale.setDefault(Locale("tr", "TR")) + assertThat(UsStateLookup.stateFromGrid(context, "bk08")).isEqualTo("HI") + } finally { + Locale.setDefault(previous) + } + } } diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/WorkedAllStatesTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/WorkedAllStatesTest.kt new file mode 100644 index 000000000..55463f28f --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/WorkedAllStatesTest.kt @@ -0,0 +1,149 @@ +package radio.ks3ckc.ft8af.ui.logbook + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.util.Locale + +/** + * Unit tests for the Worked All States (WAS) award logic: [workedStates] + * (grids -> distinct worked-state set, resolved through an injected grid->state + * lambda) and [neededStates] (the 50 WAS states minus those already worked). + * + * All plain-JVM (no Robolectric) — the helpers take Strings/collections and a + * plain lambda, so no Android context is needed. The production caller injects + * `UsStateLookup.stateFromGrid(context, grid)` as the resolver. + */ +class WorkedAllStatesTest { + + // A tiny fake grid->state resolver for the tests, independent of the app's + // real (asset-backed) UsStateLookup. + private val fakeGridStates = mapOf( + "FN31" to "CT", + "FN42" to "MA", + "DM79" to "CO", + "CM87" to "CA", + "BL11" to "HI", + "BP51" to "AK", + "FM18" to "DC", // District of Columbia — NOT a WAS state + "IO91" to null, // resolves to no US state (a DX grid) + ) + + private fun resolver(grid: String): String? = fakeGridStates[grid.take(4).uppercase(Locale.ROOT)] + + @Test + fun thereAreExactly50WasStatesAndTheyAreDistinct() { + assertThat(WAS_STATES).hasSize(50) + assertThat(WAS_STATES.toSet()).hasSize(50) + // DC is deliberately not a WAS state. + assertThat(WAS_STATES).doesNotContain("DC") + // Spot-check a few corners. + assertThat(WAS_STATES).containsAtLeast("AK", "HI", "CA", "ME", "FL") + } + + @Test + fun workedStatesCollectsDistinctResolvedStates() { + val grids = listOf("FN31pr", "FN42", "DM79", "CM87", "BL11", "BP51") + val worked = workedStates(grids) { resolver(it) } + assertThat(worked).containsExactly("CT", "MA", "CO", "CA", "HI", "AK") + } + + @Test + fun workedStatesDedupesSameStateFromDifferentGrids() { + // Two different CT/MA grids collapse to one entry each. + val grids = listOf("FN31", "FN31aa", "FN42", "FN42zz") + assertThat(workedStates(grids) { resolver(it) }).containsExactly("CT", "MA") + } + + @Test + fun workedStatesExcludesDcAndUnresolvedGrids() { + val grids = listOf("FM18", "IO91", "FN31") + // FM18 -> DC (excluded), IO91 -> null, FN31 -> CT (kept). + assertThat(workedStates(grids) { resolver(it) }).containsExactly("CT") + } + + @Test + fun workedStatesIgnoresNullGrids() { + val grids = listOf(null, "FN31", null) + assertThat(workedStates(grids) { resolver(it) }).containsExactly("CT") + } + + @Test + fun workedStatesIsEmptyWhenNothingResolves() { + assertThat(workedStates(listOf("IO91", "FM18")) { resolver(it) }).isEmpty() + assertThat(workedStates(emptyList()) { resolver(it) }).isEmpty() + } + + @Test + fun workedStatesUpperCasesLocaleInsensitively() { + // A resolver returning a lower-case "il" under a Turkish locale must still + // match the ASCII "IL" WAS state — Locale.ROOT keeps the dot off the I. + val previous = Locale.getDefault() + try { + Locale.setDefault(Locale("tr", "TR")) + val worked = workedStates(listOf("EN50")) { "il" } + assertThat(worked).containsExactly("IL") + } finally { + Locale.setDefault(previous) + } + } + + @Test + fun neededStatesIsAll50WhenNothingWorked() { + val needed = neededStates(emptySet()) + assertThat(needed).hasSize(50) + assertThat(needed).containsExactlyElementsIn(WAS_STATES).inOrder() + } + + @Test + fun neededStatesRemovesWorkedStatesKeepingCanonicalOrder() { + val needed = neededStates(setOf("CA", "NY", "TX")) + assertThat(needed).hasSize(47) + assertThat(needed).containsNoneOf("CA", "NY", "TX") + // Order still follows WAS_STATES (ME precedes MD precedes MA ...). + assertThat(needed).containsAtLeast("ME", "MD", "MA").inOrder() + } + + @Test + fun neededStatesIsEmptyWhenAll50Worked() { + assertThat(neededStates(WAS_STATES.toSet())).isEmpty() + } + + @Test + fun neededStatesPreviewReturnsWholeListWhenUnderCap() { + val (shown, overflow) = neededStatesPreview(listOf("MT", "ND", "WY"), 16) + assertThat(shown).containsExactly("MT", "ND", "WY").inOrder() + assertThat(overflow).isEqualTo(0) + } + + @Test + fun neededStatesPreviewCapsAndReportsOverflow() { + val remaining = WAS_STATES // 50 states + val (shown, overflow) = neededStatesPreview(remaining, 16) + assertThat(shown).hasSize(16) + assertThat(shown).containsExactlyElementsIn(remaining.take(16)).inOrder() + assertThat(overflow).isEqualTo(34) + } + + @Test + fun neededStatesPreviewWithZeroCapShowsNothing() { + val (shown, overflow) = neededStatesPreview(listOf("MT", "ND"), 0) + assertThat(shown).isEmpty() + assertThat(overflow).isEqualTo(2) + } + + @Test + fun neededStatesPreviewOfEmptyListIsEmpty() { + val (shown, overflow) = neededStatesPreview(emptyList(), 16) + assertThat(shown).isEmpty() + assertThat(overflow).isEqualTo(0) + } + + @Test + fun neededStatesIgnoresNonWasEntriesLikeDc() { + // A stray "DC" in the worked set must not remove a real state nor error. + val needed = neededStates(setOf("DC", "ca")) + assertThat(needed).hasSize(49) + assertThat(needed).doesNotContain("CA") + assertThat(needed).contains("NY") + } +} From 1de7bcafcb822fd7f48b12dc3694125a980119ec Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 24 Jul 2026 10:26:49 -0500 Subject: [PATCH 087/113] Show a station's gray-line / sun status when you tap it (#683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Show a station's gray-line / sun status when you tap it Tapping a decoded station now shows a "Gray line" line in the QSO sheet: whether that station is in daylight, night, or on the gray line right now, plus a countdown to their next sunrise/sunset. The gray line — the moving band of sunrise/sunset sweeping the Earth — is where HF propagation is briefly enhanced, so DXers deliberately time calls to a station's sunrise/sunset. FT8AF already draws the terminator on the world map (#675); this answers the same question for the one station you're about to work, right where you decide whether to call ("sunset in 12m — call now"). Implementation reuses the map's NOAA solar math (subsolarPoint / solarElevationDeg). New pure helpers in ui/map/SolarStatus.kt: - solarSnapshot(lat, lon, utc): current elevation, day/night, on-gray-line, and the next horizon crossing (found by scanning forward and interpolating the zero crossing; null in polar day/night where the Sun never crosses). - grayLineDisplay(): maps a snapshot to resource-free display tokens. - formatSolarCountdown(): compact "1h 20m" / "45m" / "now". The QSO sheet stays a thin Composable wrapper (GrayLineRow) that maps the tokens to localized strings; it computes once on open (the state changes over minutes) and renders nothing when the station's grid is unknown. Covered by SolarStatusTest (day/night, next sunrise/sunset, gray-line band, polar midnight-sun / polar-night, countdown formatting, display tokens). Co-Authored-By: Claude Opus 4.8 (1M context) * Gray line: render a grammatical, localized 'at sunrise/sunset' for the now case formatSolarCountdown returned the hardcoded 'now', which the QSO sheet interpolated into 'sunrise in %s' -> 'sunrise in now' (ungrammatical, and 'now' was a non-localizable Kotlin literal). It now returns an empty string to flag the <1-minute case, and the sheet picks a dedicated qso_grayline_ sunrise_now / _sunset_now resource. Tests updated for the empty-countdown flag plus a display-token case. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt | 72 ++++++ .../radio/ks3ckc/ft8af/ui/map/SolarStatus.kt | 167 ++++++++++++ .../src/main/res/values/strings_compose.xml | 11 + .../ks3ckc/ft8af/ui/map/SolarStatusTest.kt | 242 ++++++++++++++++++ 4 files changed, 492 insertions(+) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/SolarStatus.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/SolarStatusTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt index 271d840aa..bcc78bf02 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt @@ -63,9 +63,14 @@ import radio.ks3ckc.ft8af.ui.components.FT8AFIcons import radio.ks3ckc.ft8af.ui.components.GlassCard import radio.ks3ckc.ft8af.ui.components.QsoStatus import radio.ks3ckc.ft8af.ui.components.StatusPill +import radio.ks3ckc.ft8af.ui.map.GrayLineDetail +import radio.ks3ckc.ft8af.ui.map.GrayLineDisplay +import radio.ks3ckc.ft8af.ui.map.GrayLinePhase import radio.ks3ckc.ft8af.ui.map.UsStateOutlines import radio.ks3ckc.ft8af.ui.map.WorldOutlines import radio.ks3ckc.ft8af.ui.map.forEachRingVertex +import radio.ks3ckc.ft8af.ui.map.grayLineDisplay +import radio.ks3ckc.ft8af.ui.map.solarSnapshot import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -150,6 +155,10 @@ private fun QsoSheetContent( // -- Last heard: relative recency of this decode, above the map -- LastHeardRow(utcTimeMillis = message.utcTime, mainViewModel = mainViewModel) + // -- Gray line: is the DX at their sunrise/sunset right now, and how long + // until they are? A propagation timing aid; renders nothing without a grid. + GrayLineRow(grid = message.maidenGrid) + Spacer(modifier = Modifier.height(12.dp)) // -- Worked before: prior-QSO history with this station (renders nothing @@ -937,6 +946,69 @@ private fun LastHeardRow(utcTimeMillis: Long, mainViewModel: MainViewModel) { } } +/** + * Gray-line / sun status for the tapped station. Shows whether the DX is in + * daylight, night, or on the gray line right now, plus a countdown to their next + * sunrise/sunset — the moments HF propagation to them is briefly enhanced. + * + * Renders nothing when the station's grid is unknown (nothing to place on the + * globe). The snapshot is computed once when the sheet opens: the state changes + * over minutes, and rescanning a 26-hour window every second would be wasteful, + * so we deliberately do not observe the ticking clock here. + */ +@Composable +private fun GrayLineRow(grid: String?) { + val latLon = remember(grid) { gridToLatLon(grid) } ?: return + val display = remember(grid) { + grayLineDisplay(solarSnapshot(latLon.first, latLon.second, UtcTimer.getSystemTime())) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.qso_grayline_label), + color = TextFaint, + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.06.sp, + ) + val onGrayLine = display.phase == GrayLinePhase.GRAY_LINE + Text( + text = grayLineStatusText(display), + color = if (onGrayLine) Accent else TextPrimary, + fontFamily = GeistMonoFamily, + fontWeight = if (onGrayLine) FontWeight.Bold else FontWeight.SemiBold, + fontSize = 13.sp, + ) + } +} + +/** Map [GrayLineDisplay] tokens to a localized "Daylight · sunset in 1h 20m" line. */ +@Composable +private fun grayLineStatusText(display: GrayLineDisplay): String { + val phaseText = when (display.phase) { + GrayLinePhase.GRAY_LINE -> stringResource(R.string.qso_grayline_now) + GrayLinePhase.DAYLIGHT -> stringResource(R.string.qso_grayline_daylight) + GrayLinePhase.NIGHT -> stringResource(R.string.qso_grayline_night) + } + val detailText = when (display.detail) { + // An empty countdown flags the "happening now" case: use a grammatical, + // localized "at sunrise/sunset" string instead of "sunrise in now". + GrayLineDetail.SUNRISE -> + if (display.countdown.isEmpty()) stringResource(R.string.qso_grayline_sunrise_now) + else stringResource(R.string.qso_grayline_sunrise_in, display.countdown) + GrayLineDetail.SUNSET -> + if (display.countdown.isEmpty()) stringResource(R.string.qso_grayline_sunset_now) + else stringResource(R.string.qso_grayline_sunset_in, display.countdown) + GrayLineDetail.MIDNIGHT_SUN -> stringResource(R.string.qso_grayline_midnight_sun) + GrayLineDetail.POLAR_NIGHT -> stringResource(R.string.qso_grayline_polar_night) + } + return "$phaseText · $detailText" +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/SolarStatus.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/SolarStatus.kt new file mode 100644 index 000000000..1bfbabcef --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/SolarStatus.kt @@ -0,0 +1,167 @@ +package radio.ks3ckc.ft8af.ui.map + +import kotlin.math.abs +import kotlin.math.roundToLong + +/** + * Per-station solar / gray-line status. + * + * HF propagation is briefly enhanced along the "gray line" — the moving band of + * sunrise/sunset — so a DX operator who knows a distant station is *at* their + * sunrise/sunset (or how long until they are) can time a call for the best shot. + * The map already draws the terminator; this file answers the same question for + * a single point (the station's grid): are they in daylight, night, or on the + * gray line right now, and when is their next sunrise/sunset? + * + * Everything here is a pure function of (lat, lon, UTC) built on the same + * [subsolarPoint] / [solarElevationDeg] math as the map overlay, so the QSO + * sheet stays a thin wrapper and the logic is unit-tested directly. + */ + +/** Which way the Sun is about to cross the horizon at a point. */ +internal enum class SolarEventKind { SUNRISE, SUNSET } + +/** + * A point's solar situation at an instant. + * + * @param elevationDeg the Sun's angle above the horizon (negative = below). + * @param isDay whether the Sun is above the horizon. + * @param onGrayLine whether the Sun is within the gray-line band of the horizon. + * @param nextKind the next horizon crossing, or null in polar day/night where no + * crossing occurs within the scan window. + * @param minutesToNext whole minutes until [nextKind]; 0 when [nextKind] is null. + */ +internal data class SolarSnapshot( + val elevationDeg: Double, + val isDay: Boolean, + val onGrayLine: Boolean, + val nextKind: SolarEventKind?, + val minutesToNext: Int, +) + +/** Half-width (degrees) of the gray-line band on either side of the horizon. */ +internal const val GRAY_LINE_HALF_WIDTH_DEG: Double = 6.0 + +/** Step used when scanning forward for the next sunrise/sunset. */ +private const val EVENT_SCAN_STEP_MS: Long = 2L * 60L * 1000L + +/** How far ahead to scan; a little over a day guarantees at least one crossing + * outside the polar regions. */ +private const val EVENT_SCAN_SPAN_MS: Long = 26L * 60L * 60L * 1000L + +/** + * The solar situation at [lat]/[lon] for the UTC instant [utcMillis]. + * + * "Day" is elevation ≥ 0; the gray line is |elevation| ≤ [grayLineHalfWidthDeg]. + * The next crossing is found by scanning forward in [EVENT_SCAN_STEP_MS] steps + * and linearly interpolating the exact horizon crossing between the two + * bracketing samples — accurate to well under a minute, which is plenty for an + * operating aid. Near the poles a full day can pass with the Sun never crossing + * the horizon (midnight sun / polar night); there [nextKind] is null. + */ +internal fun solarSnapshot( + lat: Double, + lon: Double, + utcMillis: Long, + grayLineHalfWidthDeg: Double = GRAY_LINE_HALF_WIDTH_DEG, +): SolarSnapshot { + val elevNow = solarElevationDeg(lat, lon, subsolarPoint(utcMillis)) + + var prevElev = elevNow + var t = utcMillis + EVENT_SCAN_STEP_MS + val end = utcMillis + EVENT_SCAN_SPAN_MS + var nextKind: SolarEventKind? = null + var crossingMs = 0L + while (t <= end) { + val elev = solarElevationDeg(lat, lon, subsolarPoint(t)) + // A crossing happens when consecutive samples straddle the horizon. + if (prevElev < 0.0 && elev >= 0.0) { + nextKind = SolarEventKind.SUNRISE + } else if (prevElev >= 0.0 && elev < 0.0) { + nextKind = SolarEventKind.SUNSET + } + if (nextKind != null) { + // Linear interpolation of the exact zero crossing within this step. + val span = elev - prevElev + val frac = if (span == 0.0) 0.0 else (0.0 - prevElev) / span + val prevT = t - EVENT_SCAN_STEP_MS + crossingMs = prevT + (frac * EVENT_SCAN_STEP_MS).roundToLong() + break + } + prevElev = elev + t += EVENT_SCAN_STEP_MS + } + + val minutesToNext = if (nextKind == null) { + 0 + } else { + ((crossingMs - utcMillis).coerceAtLeast(0L) / 60000L).toInt() + } + + return SolarSnapshot( + elevationDeg = elevNow, + isDay = elevNow >= 0.0, + onGrayLine = abs(elevNow) <= grayLineHalfWidthDeg, + nextKind = nextKind, + minutesToNext = minutesToNext, + ) +} + +/** The three visual phases of the gray-line hint. */ +internal enum class GrayLinePhase { GRAY_LINE, DAYLIGHT, NIGHT } + +/** The secondary detail: the next event, or a polar no-event state. */ +internal enum class GrayLineDetail { SUNRISE, SUNSET, MIDNIGHT_SUN, POLAR_NIGHT } + +/** + * What the gray-line hint should say, as resource-free tokens plus a preformatted + * [countdown]. The QSO-sheet Composable maps [phase]/[detail] to localized strings + * (so wording stays translatable) while this decision stays pure and unit-tested. + * [countdown] is empty for the polar no-event details. + */ +internal data class GrayLineDisplay( + val phase: GrayLinePhase, + val detail: GrayLineDetail, + val countdown: String, +) + +/** Resolve a [SolarSnapshot] into the tokens the gray-line hint should display. */ +internal fun grayLineDisplay(s: SolarSnapshot): GrayLineDisplay { + val phase = when { + s.onGrayLine -> GrayLinePhase.GRAY_LINE + s.isDay -> GrayLinePhase.DAYLIGHT + else -> GrayLinePhase.NIGHT + } + return when (s.nextKind) { + SolarEventKind.SUNRISE -> + GrayLineDisplay(phase, GrayLineDetail.SUNRISE, formatSolarCountdown(s.minutesToNext)) + SolarEventKind.SUNSET -> + GrayLineDisplay(phase, GrayLineDetail.SUNSET, formatSolarCountdown(s.minutesToNext)) + null -> + GrayLineDisplay( + phase, + if (s.isDay) GrayLineDetail.MIDNIGHT_SUN else GrayLineDetail.POLAR_NIGHT, + "", + ) + } +} + +/** + * Format a whole-minute countdown as a compact "1h 20m" / "45m" string, or the + * empty string when the event is happening now (< 1 minute away). Used for the + * "sunset in …" / "sunrise in …" gray-line hint; the empty return flags the + * "now" case so the caller can pick a grammatical, localized "at sunrise" / + * "at sunset" resource instead of interpolating a hardcoded "now" into + * "sunrise in …". Kept pure (no Android resources) so it is unit-tested directly. + */ +internal fun formatSolarCountdown(minutes: Int): String { + val m = minutes.coerceAtLeast(0) + if (m < 1) return "" + val hours = m / 60 + val mins = m % 60 + return when { + hours <= 0 -> "${mins}m" + mins == 0 -> "${hours}h" + else -> "${hours}h ${mins}m" + } +} diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 61d3259b2..70871661f 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -238,6 +238,17 @@ %1$dm ago %1$dh ago %1$dd ago + + Gray line + On the gray line now + Daylight + Night + sunrise in %1$s + sunset in %1$s + at sunrise + at sunset + midnight sun + polar night Stats diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/SolarStatusTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/SolarStatusTest.kt new file mode 100644 index 000000000..1c8f24026 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/SolarStatusTest.kt @@ -0,0 +1,242 @@ +package radio.ks3ckc.ft8af.ui.map + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.util.Calendar +import java.util.GregorianCalendar +import java.util.TimeZone + +/** + * Pure unit tests for the per-station gray-line / sun-status helper. No Android + * or Compose runtime is touched, so these run as plain JVM tests. + */ +class SolarStatusTest { + + private fun utc(year: Int, month: Int, day: Int, hour: Int, minute: Int = 0): Long = + GregorianCalendar(TimeZone.getTimeZone("UTC")).apply { + clear() + set(year, month, day, hour, minute, 0) + }.timeInMillis + + // ----------------------------------------------------------------------- + // Day / night classification + // ----------------------------------------------------------------------- + + @Test + fun localSolarNoon_isDaytime() { + // Prime meridian at 12:00 UTC near an equinox: the Sun is high overhead. + val s = solarSnapshot(lat = 0.0, lon = 0.0, utcMillis = utc(2024, Calendar.MARCH, 20, 12)) + assertThat(s.isDay).isTrue() + assertThat(s.elevationDeg).isGreaterThan(60.0) + assertThat(s.onGrayLine).isFalse() + } + + @Test + fun localMidnight_isNight() { + // Prime meridian at 00:00 UTC: the Sun is on the far side of the Earth. + val s = solarSnapshot(lat = 0.0, lon = 0.0, utcMillis = utc(2024, Calendar.MARCH, 20, 0)) + assertThat(s.isDay).isFalse() + assertThat(s.elevationDeg).isLessThan(-60.0) + assertThat(s.onGrayLine).isFalse() + } + + // ----------------------------------------------------------------------- + // Next sunrise / sunset + // ----------------------------------------------------------------------- + + @Test + fun daytime_nextEventIsSunset() { + val s = solarSnapshot(lat = 0.0, lon = 0.0, utcMillis = utc(2024, Calendar.MARCH, 20, 12)) + assertThat(s.nextKind).isEqualTo(SolarEventKind.SUNSET) + // At the equator on an equinox, sunset is ~6 h after local noon. + assertThat(s.minutesToNext).isWithin(30).of(6 * 60) + } + + @Test + fun night_nextEventIsSunrise() { + val s = solarSnapshot(lat = 0.0, lon = 0.0, utcMillis = utc(2024, Calendar.MARCH, 20, 3)) + assertThat(s.nextKind).isEqualTo(SolarEventKind.SUNRISE) + // 03:00 UTC on the prime meridian is a few hours before ~06:00 sunrise. + assertThat(s.minutesToNext).isWithin(30).of(3 * 60) + } + + @Test + fun sunriseCrossing_flipsToDaytime() { + // Just before sunrise it is night; the very next event is a sunrise, and + // an hour later the same point reads as daytime. + val beforeDawn = solarSnapshot(lat = 0.0, lon = 0.0, utcMillis = utc(2024, Calendar.MARCH, 20, 5)) + assertThat(beforeDawn.isDay).isFalse() + assertThat(beforeDawn.nextKind).isEqualTo(SolarEventKind.SUNRISE) + + val afterDawn = solarSnapshot(lat = 0.0, lon = 0.0, utcMillis = utc(2024, Calendar.MARCH, 20, 7)) + assertThat(afterDawn.isDay).isTrue() + } + + // ----------------------------------------------------------------------- + // Gray line + // ----------------------------------------------------------------------- + + @Test + fun nearSunrise_isOnGrayLine() { + // Sweep the dawn hour and confirm the point passes through the gray-line + // band (|elevation| within the half-width) as the Sun crosses the horizon. + var sawGrayLine = false + for (minute in 0 until 120 step 4) { + val s = solarSnapshot( + lat = 0.0, + lon = 0.0, + utcMillis = utc(2024, Calendar.MARCH, 20, 5, minute % 60) + + (minute / 60) * 3600_000L, + ) + if (s.onGrayLine) sawGrayLine = true + } + assertThat(sawGrayLine).isTrue() + } + + @Test + fun grayLineWidth_isConfigurable() { + // A point 4° below the horizon is on the gray line at the default 6° + // half-width but not at a tight 2° one. + // Find an instant where the equatorial Sun sits a few degrees down. + var base = utc(2024, Calendar.MARCH, 20, 5, 30) + // Nudge forward until elevation lands in (-5, -3). + var chosen = -1L + var m = 0 + while (m < 120) { + val t = base + m * 60_000L + val elev = solarSnapshot(0.0, 0.0, t).elevationDeg + if (elev in -5.0..-3.0) { + chosen = t + break + } + m++ + } + assertThat(chosen).isGreaterThan(0L) + assertThat(solarSnapshot(0.0, 0.0, chosen, grayLineHalfWidthDeg = 6.0).onGrayLine).isTrue() + assertThat(solarSnapshot(0.0, 0.0, chosen, grayLineHalfWidthDeg = 2.0).onGrayLine).isFalse() + } + + // ----------------------------------------------------------------------- + // Polar day / night + // ----------------------------------------------------------------------- + + @Test + fun arcticSummer_hasNoSunsetWithinScan() { + // North Pole at the June solstice: midnight sun — the Sun never sets, so + // there is no next crossing. + val s = solarSnapshot(lat = 89.0, lon = 0.0, utcMillis = utc(2024, Calendar.JUNE, 21, 12)) + assertThat(s.isDay).isTrue() + assertThat(s.nextKind).isNull() + assertThat(s.minutesToNext).isEqualTo(0) + } + + @Test + fun arcticWinter_hasNoSunriseWithinScan() { + // North Pole at the December solstice: polar night — the Sun never rises. + val s = solarSnapshot(lat = 89.0, lon = 0.0, utcMillis = utc(2024, Calendar.DECEMBER, 21, 12)) + assertThat(s.isDay).isFalse() + assertThat(s.nextKind).isNull() + } + + // ----------------------------------------------------------------------- + // Countdown formatting + // ----------------------------------------------------------------------- + + @Test + fun countdown_formatsHoursAndMinutes() { + assertThat(formatSolarCountdown(80)).isEqualTo("1h 20m") + assertThat(formatSolarCountdown(45)).isEqualTo("45m") + assertThat(formatSolarCountdown(120)).isEqualTo("2h") + assertThat(formatSolarCountdown(60)).isEqualTo("1h") + } + + @Test + fun countdown_zeroOrNegative_isEmptyNowFlag() { + // < 1 minute away returns "" so the caller can render a grammatical, + // localized "at sunrise/sunset" instead of "sunrise in now". + assertThat(formatSolarCountdown(0)).isEmpty() + assertThat(formatSolarCountdown(-5)).isEmpty() + } + + // ----------------------------------------------------------------------- + // Display tokens + // ----------------------------------------------------------------------- + + @Test + fun display_daytime_showsDaylightAndSunset() { + val d = grayLineDisplay( + SolarSnapshot( + elevationDeg = 40.0, isDay = true, onGrayLine = false, + nextKind = SolarEventKind.SUNSET, minutesToNext = 80, + ), + ) + assertThat(d.phase).isEqualTo(GrayLinePhase.DAYLIGHT) + assertThat(d.detail).isEqualTo(GrayLineDetail.SUNSET) + assertThat(d.countdown).isEqualTo("1h 20m") + } + + @Test + fun display_night_showsNightAndSunrise() { + val d = grayLineDisplay( + SolarSnapshot( + elevationDeg = -30.0, isDay = false, onGrayLine = false, + nextKind = SolarEventKind.SUNRISE, minutesToNext = 45, + ), + ) + assertThat(d.phase).isEqualTo(GrayLinePhase.NIGHT) + assertThat(d.detail).isEqualTo(GrayLineDetail.SUNRISE) + assertThat(d.countdown).isEqualTo("45m") + } + + @Test + fun display_eventImminent_hasEmptyCountdownForNowRendering() { + // Sunset < 1 minute away: detail is still SUNSET, but the countdown is + // empty so the QSO sheet renders the localized "at sunset" now-string. + val d = grayLineDisplay( + SolarSnapshot( + elevationDeg = 0.5, isDay = true, onGrayLine = true, + nextKind = SolarEventKind.SUNSET, minutesToNext = 0, + ), + ) + assertThat(d.detail).isEqualTo(GrayLineDetail.SUNSET) + assertThat(d.countdown).isEmpty() + } + + @Test + fun display_onGrayLine_overridesDayNightPhase() { + val d = grayLineDisplay( + SolarSnapshot( + elevationDeg = 2.0, isDay = true, onGrayLine = true, + nextKind = SolarEventKind.SUNSET, minutesToNext = 12, + ), + ) + assertThat(d.phase).isEqualTo(GrayLinePhase.GRAY_LINE) + assertThat(d.detail).isEqualTo(GrayLineDetail.SUNSET) + } + + @Test + fun display_polarDay_showsMidnightSunWithNoCountdown() { + val d = grayLineDisplay( + SolarSnapshot( + elevationDeg = 15.0, isDay = true, onGrayLine = false, + nextKind = null, minutesToNext = 0, + ), + ) + assertThat(d.phase).isEqualTo(GrayLinePhase.DAYLIGHT) + assertThat(d.detail).isEqualTo(GrayLineDetail.MIDNIGHT_SUN) + assertThat(d.countdown).isEmpty() + } + + @Test + fun display_polarNight_showsPolarNightWithNoCountdown() { + val d = grayLineDisplay( + SolarSnapshot( + elevationDeg = -10.0, isDay = false, onGrayLine = false, + nextKind = null, minutesToNext = 0, + ), + ) + assertThat(d.phase).isEqualTo(GrayLinePhase.NIGHT) + assertThat(d.detail).isEqualTo(GrayLineDetail.POLAR_NIGHT) + assertThat(d.countdown).isEmpty() + } +} From 726627c93b089bc4a5c510b0598a6c0ffd9bd0fc Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 24 Jul 2026 10:27:37 -0500 Subject: [PATCH 088/113] Add Max 73 Sends option to cap RR73/73 repeats per QSO (#690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Max 73 Sends option to cap RR73/73 repeats per QSO The no-reply caps could not bound how many RR73/73s actually go on the air: with Stop After set to N, RR73 repeats up to 2N unanswered cycles; any decode from the partner resets the no-reply counter (so a partner re-sending R+report keeps RR73 going indefinitely); and every received RR73 re-triggers a 73 reply with no cap at all. New Transmission setting "Max 73 Sends" (Auto/1-10, default Auto = classic behavior) counts actual order-4/5 transmissions per target and completes the QSO when the cap is hit — the contact is already logged at the RR73/73 stage. A capped station's leftover R+report/RR73 is gated out of the caller-pickup scans (same cycle and after) so it cannot restart the loop; a fresh CQ/grid from them still gets answered and lifts the gate. Co-Authored-By: Claude Fable 5 * Max 73 Sends: gate only continuation orders 3-4, not CQ/73 isCappedContinuation used msgOrder >= 3, which also matched a fresh CQ (checkFunOrder returns 6) and an exact 73 (order 5) from the capped station — so a capped station's new CQ was silently ignored instead of answered, contradicting the intended 'order 3-4' behavior. Restrict the gate to orders 3 and 4, correct the Javadoc (CQ is order 6, not below 3), and add regression assertions for orders 5 and 6. Also reword the setting description 'Most' -> 'Maximum'. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Fable 5 --- .../java/com/k1af/ft8af/GeneralVariables.java | 7 + .../com/k1af/ft8af/database/DatabaseOpr.java | 3 + .../ft8af/ft8transmit/FT8TransmitSignal.java | 109 +++++++++- .../ft8af/ui/settings/TransmissionSettings.kt | 36 ++++ .../main/res/values-ar/strings_compose.xml | 3 + .../main/res/values-cs/strings_compose.xml | 3 + .../main/res/values-es/strings_compose.xml | 3 + .../main/res/values-fr/strings_compose.xml | 3 + .../main/res/values-in/strings_compose.xml | 3 + .../main/res/values-it/strings_compose.xml | 3 + .../main/res/values-ja/strings_compose.xml | 3 + .../main/res/values-ko/strings_compose.xml | 3 + .../main/res/values-nl/strings_compose.xml | 3 + .../main/res/values-pl/strings_compose.xml | 3 + .../main/res/values-ru/strings_compose.xml | 3 + .../main/res/values-tr/strings_compose.xml | 3 + .../main/res/values-uk/strings_compose.xml | 3 + .../res/values-zh-rCN/strings_compose.xml | 3 + .../res/values-zh-rTW/strings_compose.xml | 3 + .../src/main/res/values/strings_compose.xml | 3 + .../ft8transmit/Max73SendsSequencerTest.java | 201 ++++++++++++++++++ .../ft8af/ft8transmit/Max73SendsTest.java | 97 +++++++++ 22 files changed, 496 insertions(+), 5 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/Max73SendsSequencerTest.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/Max73SendsTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 1685a8f1e..1b08722c7 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -543,6 +543,13 @@ public static String excludedBandsToCsv() { public static int noReplyCount = 0;//Number of times with no reply + // Hard cap on RR73/73 transmissions per QSO before moving on; 0==Auto + // (classic behavior: RR73 repeats until the no-reply caps fire, 73 re-sends + // for every RR73 received). Unlike the no-reply caps this counts actual + // sends, so it also bounds the loops where the partner keeps transmitting + // (re-sent R+report / RR73) and the no-reply counter never accumulates. + public static int max73Sends = 0; + //The following 4 parameters are for ICOM network connection public static String icomIp = "255.255.255.255"; public static int icomUdpPort = 50001; diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index b9fa037bf..6e79ee60e 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2790,6 +2790,9 @@ protected Void doInBackground(Void... voids) { if (name.equalsIgnoreCase("noReplyLimit")) {// GeneralVariables.noReplyLimit = parseConfigInt(result, 0); } + if (name.equalsIgnoreCase("max73Sends")) {//Max RR73/73 sends per QSO; 0==Auto + GeneralVariables.max73Sends = parseConfigInt(result, 0); + } if (name.equalsIgnoreCase("autoFollowCQ")) {//Auto-follow CQ GeneralVariables.autoFollowCQ = result.equals("1"); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java index 1099c879c..9fc2cdd3d 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java @@ -134,6 +134,25 @@ public class FT8TransmitSignal { // benefit, then return to CQ / next caller. private static final int RR73_GIVEUP_CYCLES = 3; + // Actual RR73/73 (order 4/5) transmissions for the current target, counted + // at doTransmit() time. The no-reply caps above can't bound the loops where + // the partner keeps transmitting (re-sent R+report at our RR73, repeated + // RR73 at our 73) because every decode from them resets noReplyCount; when + // the user sets GeneralVariables.max73Sends (> 0), this counter caps those + // too. Reset wherever the target changes (generateFun) and on TX stop. + // Package-visible for testing. + volatile int finalAckSends = 0; + + // Callsign whose QSO the Max 73 Sends cap just ended. Their leftover (and + // subsequent) R+report/RR73 continuation messages to me must not re-answer + // the very loop the cap ended — without this the same-cycle continuation + // scan (checkCQMeOrFollowCQMessage / enqueueCaller) restarts the QSO + // immediately, because those messages are addressed to me and are not an + // exact "73" (checkFun5). A fresh CQ/grid/report from them still gets + // answered, which also clears the gate (see setTransmit). Cleared on TX + // stop as well. Package-visible for testing. + volatile String cappedCallsign = ""; + // Caller queue: stations that called us while we're in an active QSO private static final int MAX_QUEUE_SIZE = 10; private final ArrayList callerQueue = new ArrayList<>(); @@ -398,6 +417,9 @@ public void doTransmit() { return; } Log.d(TAG, "doTransmit: Starting transmit..."); + if (functionOrder == 4 || functionOrder == 5) { + finalAckSends++; + } // Record exactly what goes on the air this cycle, alongside the existing // serial.send TX1/TX0 keying lines, so the QSO trace shows the message text. GeneralVariables.fileLog("QSO: TX slot=" + sequential + " order=" + functionOrder @@ -431,6 +453,12 @@ public void setTransmit(TransmitCallsign transmitCallsign toCallsign = transmitCallsign;// set the call target //mutableToCallsign.postValue(toCallsign);// set the call target + // Starting a fresh QSO with the station the Max 73 Sends cap gated + // (their new CQ/grid, or the user tapping their decode) lifts the gate. + if (!cappedCallsign.isEmpty() && cappedCallsign.equals(transmitCallsign.callsign)) { + cappedCallsign = ""; + } + if (functionOrder == -1) {// this is a reply message // at this point toMaidenheadGrid is extraInfo this.functionOrder = GeneralVariables.checkFunOrderByExtraInfo(toMaidenheadGrid) + 1; @@ -566,6 +594,7 @@ public void generateFun() { String currentTarget = (toCallsign != null && toCallsign.callsign != null) ? toCallsign.callsign : ""; if (shouldResetNoReplyCount(currentTarget, lastNoReplyTarget)) { GeneralVariables.noReplyCount = 0; + finalAckSends = 0; lastNoReplyTarget = currentTarget; } synchronized (functionList) { @@ -1382,6 +1411,8 @@ private boolean checkCQMeOrFollowCQMessage(ArrayList messages, boole for (int i = messages.size() - 1; i >= 0; i--) {// check if anyone is CQing me (TO:ME, not 73) Ft8Message msg = messages.get(i); if (isExcludeMessage(msg)) continue;// check if this is an excluded message + if (isCappedContinuation(cappedCallsign, msg.getCallsignFrom(), + GeneralVariables.checkFunOrder(msg))) continue;// Max 73 Sends: don't restart the capped loop if (toCallsign == null) break; //if (msg.getCallsignTo().equals(GeneralVariables.myCallsign) @@ -1401,6 +1432,8 @@ private boolean checkCQMeOrFollowCQMessage(ArrayList messages, boole for (int i = messages.size() - 1; i >= 0; i--) {// check if anyone is CQing me (TO:ME, not 73) Ft8Message msg = messages.get(i); if (isExcludeMessage(msg)) continue;// check if this is an excluded message + if (isCappedContinuation(cappedCallsign, msg.getCallsignFrom(), + GeneralVariables.checkFunOrder(msg))) continue;// Max 73 Sends: don't restart the capped loop //if ((msg.getCallsignTo().equals(GeneralVariables.myCallsign) if ((GeneralVariables.checkIsMyCallsign(msg.getCallsignTo()) && !GeneralVariables.checkFun5(msg.extraInfo))) {// CQ me, not 73 @@ -1614,11 +1647,12 @@ public void parseMessageToFunction(ArrayList msgList, boolean eviden int newOrder = checkFunctionOrdFromMessages(messages);// check reply message sequence from the other party; -1 means not received // Per-cycle spine of the QSO trace: current state, what (if anything) the // other party sent us this cycle, who we're working, and the no-reply count. - GeneralVariables.fileLog(String.format("QSO: cycle%s slot=%d order=%d newOrder=%d to=%s noReply=%d/%d", + GeneralVariables.fileLog(String.format("QSO: cycle%s slot=%d order=%d newOrder=%d to=%s noReply=%d/%d 73tx=%d/%d", evidenceOnly ? "(deep)" : "", sequential, functionOrder, newOrder, toCallsign != null ? toCallsign.callsign : "null", - GeneralVariables.noReplyCount, GeneralVariables.noReplyLimit)); + GeneralVariables.noReplyCount, GeneralVariables.noReplyLimit, + finalAckSends, GeneralVariables.max73Sends)); if (newOrder != -1) {// if there is a message sequence, reply received; reset error counter GeneralVariables.noReplyCount = 0; } @@ -1626,10 +1660,20 @@ public void parseMessageToFunction(ArrayList msgList, boolean eviden // update the QSO list; if not already recorded, save it updateQSlRecordList(newOrder, toCallsign); + // User cap on RR73/73 transmissions (Max 73 Sends setting). Based on our + // own send count — deterministic, not partner silence — so it applies on + // every pass and also bounds the loops the no-reply caps can't reach: + // the partner re-sending R+report at our RR73, or repeating RR73 at our + // 73 (each received RR73 would otherwise re-trigger a 73 reply forever). + boolean ackCapReached = hasReachedMax73Sends( + GeneralVariables.max73Sends, finalAckSends, functionOrder); + // FT8 protocol: when we receive RR73/RRR (order 4), always reply with // 73 (order 5) before completing the QSO. This handler fires before - // the completion check so that we never skip the 73 reply. - if (newOrder == 4) { + // the completion check so that we never skip the 73 reply — unless the + // Max 73 Sends cap is reached, in which case we fall through and + // complete instead of re-sending yet another 73. + if (newOrder == 4 && !ackCapReached) { GeneralVariables.fileLog("QSO: rx RR73 from " + (toCallsign != null ? toCallsign.callsign : "?") + " -> reply 73"); functionOrder = 5; @@ -1642,9 +1686,18 @@ public void parseMessageToFunction(ArrayList msgList, boolean eviden // determine QSO success: other party replied 73 (5) || I am at 73 (5) and other party did not reply (-1) // or I am at RR73 (4) and no-reply threshold reached with no-reply limit enabled // or I am at RR73 (4) and the other party started calling someone else, to prevent RR73 deadlock - if (shouldCompleteQso(evidenceOnly, functionOrder, newOrder, + if (ackCapReached || shouldCompleteQso(evidenceOnly, functionOrder, newOrder, GeneralVariables.noReplyCount, GeneralVariables.noReplyLimit, functionOrder == 4 && checkTargetCallMe(messages) > 1)) { + if (ackCapReached) { + GeneralVariables.fileLog("QSO: max 73 sends reached (" + + finalAckSends + "/" + GeneralVariables.max73Sends + ")"); + // Gate this station's continuation messages out of the + // caller-pickup scans below, or their leftover RR73/R+report + // would restart the QSO in this very cycle. + cappedCallsign = toCallsign != null && toCallsign.callsign != null + ? toCallsign.callsign : ""; + } GeneralVariables.fileLog("QSO: complete with " + (toCallsign != null ? toCallsign.callsign : "?") + " (order=" + functionOrder + " newOrder=" + newOrder + ") -> reset to CQ"); @@ -2007,6 +2060,8 @@ public void setActivated(boolean activated) { // Reset retry counters so a fresh run to the same callsign // doesn't inherit a stale noReplyCount from the previous session. GeneralVariables.noReplyCount = 0; + finalAckSends = 0; + cappedCallsign = ""; lastNoReplyTarget = ""; } mutableIsActivated.postValue(activated); @@ -2350,6 +2405,48 @@ static boolean shouldCompleteQso(boolean evidenceOnly, int functionOrder, int ne && (noReplyLimit == 0));// no-reply "ignore" at RR73: QSO already logged, don't hold the run frequency for minutes } + /** + * Whether the user's "Max 73 Sends" cap ends the QSO this cycle. The cap + * counts actual RR73/73 (order 4/5) transmissions — unlike the no-reply + * caps in {@link #shouldCompleteQso}, it therefore also bounds the loops + * where the partner keeps transmitting (re-sent R+report, repeated RR73) + * and {@code noReplyCount} keeps getting reset. 0 == Auto: cap disabled, + * classic no-reply-based behavior only. Deterministic own-send evidence, + * so it applies to fast and deep passes alike. + * + *

    Package-visible for testing. + * + * @param max73Sends user cap on RR73/73 transmissions (0 = Auto/off) + * @param finalAckSends RR73/73 transmissions so far for the current target + * @param functionOrder my current message order (1-6) + */ + static boolean hasReachedMax73Sends(int max73Sends, int finalAckSends, int functionOrder) { + return max73Sends > 0 + && (functionOrder == 4 || functionOrder == 5) + && finalAckSends >= max73Sends; + } + + /** + * Whether a message is a continuation (R+report / RR73, order 3-4) of the + * QSO the Max 73 Sends cap just ended, and must therefore be ignored by the + * caller-pickup scans instead of restarting the capped loop. Only orders 3 + * and 4 are gated: a fresh CQ from the capped station (order 6) is a new QSO + * attempt and must still be answered, and an exact "73" (order 5) is a QSO + * end already filtered by the scans — so neither is treated as a + * continuation here. + * + *

    Package-visible for testing. + * + * @param cappedCallsign station the cap ended a QSO with ("" = none) + * @param fromCallsign sender of the candidate message + * @param msgOrder the message's sequence order (GeneralVariables.checkFunOrder) + */ + static boolean isCappedContinuation(String cappedCallsign, String fromCallsign, int msgOrder) { + return cappedCallsign != null && !cappedCallsign.isEmpty() + && cappedCallsign.equals(fromCallsign) + && (msgOrder == 3 || msgOrder == 4); + } + /** * Whether a deep/late-pass decode describes a QSO state we have already moved * past, and must therefore be ignored. @@ -2404,6 +2501,8 @@ public void enqueueCaller(Ft8Message msg) { if (GeneralVariables.checkIsMyCallsign(callsign)) return; if (GeneralVariables.checkIsExcludeCallsign(callsign)) return; if (GeneralVariables.checkQSLCallsign(callsign)) return; + if (isCappedContinuation(cappedCallsign, callsign, + GeneralVariables.checkFunOrder(msg))) return;// Max 73 Sends: don't queue the capped loop synchronized (callerQueue) { // Update existing entry if callsign already queued diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TransmissionSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TransmissionSettings.kt index 65b4b29ef..aaf8a644c 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TransmissionSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TransmissionSettings.kt @@ -41,6 +41,9 @@ import radio.ks3ckc.ft8af.ui.components.SettingsRow // keep a [ALC_GAP]-unit gap between the two values; clampAlcLow/clampAlcHigh use // these both to bound each slider and to keep the coerceIn range non-empty when a // restored/corrupted config persists an out-of-order pair. +// Upper bound of the "Max 73 Sends" picker (1..MAX; 0 = Auto, cap disabled). +private const val MAX_73_SENDS_MAX = 10 + private const val ALC_LOW_MIN = 10 private const val ALC_LOW_MAX = 200 private const val ALC_HIGH_MAX = 250 @@ -93,6 +96,7 @@ fun TransmissionSettings( var clearOnBandModeChange by remember { mutableStateOf(GeneralVariables.clearOnBandModeChange) } var watchdogMs by remember { mutableIntStateOf(GeneralVariables.launchSupervision) } var noReplyLimit by remember { mutableIntStateOf(GeneralVariables.noReplyLimit) } + var max73Sends by remember { mutableIntStateOf(GeneralVariables.max73Sends) } // TX Protection state var autoVolumeEnabled by remember { mutableStateOf(GeneralVariables.autoVolumeEnabled) } @@ -118,6 +122,7 @@ fun TransmissionSettings( var showWatchdog by remember { mutableStateOf(false) } var showStopAfter by remember { mutableStateOf(false) } + var showMax73 by remember { mutableStateOf(false) } var showTuneMethod by remember { mutableStateOf(false) } // Index == TuneMethod.AUTOMATIC/INTERNAL/TONE @@ -204,6 +209,28 @@ fun TransmissionSettings( ) } + // -- Max 73 Sends Picker -- + if (showMax73) { + val max73Options = mutableListOf(stringResource(R.string.settings_max_73_auto)) + for (i in 1..MAX_73_SENDS_MAX) { + max73Options.add(i.toString()) + } + ListPickerDialog( + title = stringResource(R.string.settings_max_73), + items = max73Options, + selectedIndex = max73Sends.coerceIn(0, MAX_73_SENDS_MAX), + onDismiss = { showMax73 = false }, + onSelect = { index -> + showMax73 = false + GeneralVariables.max73Sends = index + max73Sends = index + mainViewModel.databaseOpr.writeConfig( + "max73Sends", index.toString(), null, + ) + }, + ) + } + SettingsDetailScaffold( title = stringResource(R.string.settings_cat_transmission), onBack = onBack, @@ -269,6 +296,15 @@ fun TransmissionSettings( showChevron = true, onClick = { showStopAfter = true }, ) + SectionDivider() + SettingsRow( + label = stringResource(R.string.settings_max_73), + description = stringResource(R.string.settings_max_73_desc), + value = if (max73Sends == 0) stringResource(R.string.settings_max_73_auto) + else max73Sends.toString(), + showChevron = true, + onClick = { showMax73 = true }, + ) } } } diff --git a/ft8af/app/src/main/res/values-ar/strings_compose.xml b/ft8af/app/src/main/res/values-ar/strings_compose.xml index 4f202c86b..7543f8f64 100644 --- a/ft8af/app/src/main/res/values-ar/strings_compose.xml +++ b/ft8af/app/src/main/res/values-ar/strings_compose.xml @@ -406,6 +406,9 @@ إيقاف الإرسال تلقائياً بعد المهلة التوقف بعد التوقف عن النداء بعد N محاولات بلا رد + الحد الأقصى لإرسال 73 + أقصى عدد لإرسالات RR73/73 لكل اتصال قبل المتابعة + تلقائي الصيد (رد تلقائي على CQ) diff --git a/ft8af/app/src/main/res/values-cs/strings_compose.xml b/ft8af/app/src/main/res/values-cs/strings_compose.xml index 3a8745ab5..6a9b23377 100644 --- a/ft8af/app/src/main/res/values-cs/strings_compose.xml +++ b/ft8af/app/src/main/res/values-cs/strings_compose.xml @@ -406,6 +406,9 @@ Automaticky zastavit vysílání po vypršení limitu Zastavit po Zastavit volání po N neúspěšných pokusech + Max. odeslání 73 + Nejvyšší počet vysílání RR73/73 na spojení, než se pokračuje dál + Automaticky Lov (auto-odpověď na CQ) diff --git a/ft8af/app/src/main/res/values-es/strings_compose.xml b/ft8af/app/src/main/res/values-es/strings_compose.xml index 1e770f984..6bb5b1449 100644 --- a/ft8af/app/src/main/res/values-es/strings_compose.xml +++ b/ft8af/app/src/main/res/values-es/strings_compose.xml @@ -329,6 +329,9 @@ Detener la transmisión automáticamente tras un tiempo límite Detener tras Dejar de llamar tras N intentos sin respuesta + Máx. envíos de 73 + Máximo de transmisiones RR73/73 por contacto antes de continuar + Auto Caza (responder CQ automáticamente) diff --git a/ft8af/app/src/main/res/values-fr/strings_compose.xml b/ft8af/app/src/main/res/values-fr/strings_compose.xml index d277e81f7..9d1dc63a7 100644 --- a/ft8af/app/src/main/res/values-fr/strings_compose.xml +++ b/ft8af/app/src/main/res/values-fr/strings_compose.xml @@ -329,6 +329,9 @@ Arrêt automatique de l\'émission après expiration du délai Arrêter après Arrêter d\'appeler après N tentatives sans réponse + Envois 73 max + Nombre maximal de transmissions RR73/73 par contact avant de passer à la suite + Auto Chasse (réponse auto au CQ) diff --git a/ft8af/app/src/main/res/values-in/strings_compose.xml b/ft8af/app/src/main/res/values-in/strings_compose.xml index 6fb4cccc3..b5f098ee7 100644 --- a/ft8af/app/src/main/res/values-in/strings_compose.xml +++ b/ft8af/app/src/main/res/values-in/strings_compose.xml @@ -406,6 +406,9 @@ Hentikan transmisi otomatis setelah waktu habis Hentikan Setelah Berhenti memanggil setelah N percobaan tak terjawab + Maks kirim 73 + Jumlah maksimum transmisi RR73/73 per kontak sebelum melanjutkan + Otomatis Hunt (jawab CQ otomatis) diff --git a/ft8af/app/src/main/res/values-it/strings_compose.xml b/ft8af/app/src/main/res/values-it/strings_compose.xml index e42c93559..3abd66d2f 100644 --- a/ft8af/app/src/main/res/values-it/strings_compose.xml +++ b/ft8af/app/src/main/res/values-it/strings_compose.xml @@ -395,6 +395,9 @@ Interrompe automaticamente la trasmissione dopo il timeout Stop dopo Interrompi la chiamata dopo N tentativi senza risposta + Invii 73 max + Numero massimo di trasmissioni RR73/73 per contatto prima di proseguire + Auto Hunt (risposta automatica al CQ) diff --git a/ft8af/app/src/main/res/values-ja/strings_compose.xml b/ft8af/app/src/main/res/values-ja/strings_compose.xml index 08c384dee..79be96fc0 100644 --- a/ft8af/app/src/main/res/values-ja/strings_compose.xml +++ b/ft8af/app/src/main/res/values-ja/strings_compose.xml @@ -329,6 +329,9 @@ タイムアウト後に送信を自動停止します 停止条件 N 回応答がなければ呼出を停止します + 73送信の上限 + 次へ進む前のQSOごとのRR73/73送信回数の上限 + 自動 ハント (CQ 自動応答) diff --git a/ft8af/app/src/main/res/values-ko/strings_compose.xml b/ft8af/app/src/main/res/values-ko/strings_compose.xml index 242e4c3c1..4cbc72476 100644 --- a/ft8af/app/src/main/res/values-ko/strings_compose.xml +++ b/ft8af/app/src/main/res/values-ko/strings_compose.xml @@ -406,6 +406,9 @@ 시간 초과 후 송신 자동 중지 중지 기준 N회 응답 없는 호출 후 중지 + 73 송신 최대 횟수 + 다음으로 넘어가기 전 교신당 RR73/73 송신 최대 횟수 + 자동 Hunt (CQ 자동 응답) diff --git a/ft8af/app/src/main/res/values-nl/strings_compose.xml b/ft8af/app/src/main/res/values-nl/strings_compose.xml index d2bb4ba1f..e7bf58e1a 100644 --- a/ft8af/app/src/main/res/values-nl/strings_compose.xml +++ b/ft8af/app/src/main/res/values-nl/strings_compose.xml @@ -406,6 +406,9 @@ Uitzending automatisch stoppen na time-out Stoppen na Stop met roepen na N onbeantwoorde pogingen + Max. 73-zendingen + Maximaal aantal RR73/73-uitzendingen per verbinding voordat wordt doorgegaan + Auto Hunt (CQ automatisch beantwoorden) diff --git a/ft8af/app/src/main/res/values-pl/strings_compose.xml b/ft8af/app/src/main/res/values-pl/strings_compose.xml index aab3a7778..0e4d1df16 100644 --- a/ft8af/app/src/main/res/values-pl/strings_compose.xml +++ b/ft8af/app/src/main/res/values-pl/strings_compose.xml @@ -395,6 +395,9 @@ Automatyczne zatrzymanie nadawania po przekroczeniu czasu Zatrzymaj po Przestań wywoływać po N nieodebranych próbach + Maks. wysłań 73 + Maksymalna liczba transmisji RR73/73 na łączność przed przejściem dalej + Auto Polowanie (auto-odpowiedź na CQ) diff --git a/ft8af/app/src/main/res/values-ru/strings_compose.xml b/ft8af/app/src/main/res/values-ru/strings_compose.xml index 81e6f1a79..57870ffce 100644 --- a/ft8af/app/src/main/res/values-ru/strings_compose.xml +++ b/ft8af/app/src/main/res/values-ru/strings_compose.xml @@ -329,6 +329,9 @@ Автоостановка передачи по тайм-ауту Остановка после Прекратить вызов после N неотвеченных попыток + Макс. передач 73 + Максимум передач RR73/73 за связь, прежде чем перейти дальше + Авто Охота (автоответ на CQ) diff --git a/ft8af/app/src/main/res/values-tr/strings_compose.xml b/ft8af/app/src/main/res/values-tr/strings_compose.xml index dab0bdc5a..280bbc295 100644 --- a/ft8af/app/src/main/res/values-tr/strings_compose.xml +++ b/ft8af/app/src/main/res/values-tr/strings_compose.xml @@ -395,6 +395,9 @@ Zaman aşımından sonra yayını otomatik durdur Şundan Sonra Durdur N yanıtsız denemeden sonra çağrıyı durdur + Maks. 73 gönderimi + Devam etmeden önce görüşme başına en fazla RR73/73 gönderim sayısı + Otomatik Av (CQ\'lara otomatik yanıt) diff --git a/ft8af/app/src/main/res/values-uk/strings_compose.xml b/ft8af/app/src/main/res/values-uk/strings_compose.xml index 1418c3fa5..2cc02fb79 100644 --- a/ft8af/app/src/main/res/values-uk/strings_compose.xml +++ b/ft8af/app/src/main/res/values-uk/strings_compose.xml @@ -395,6 +395,9 @@ Автозупинка передавання після тайм-ауту Зупинити після Припинити виклик після N спроб без відповіді + Макс. передач 73 + Максимум передач RR73/73 за зв\'язок, перш ніж рухатися далі + Авто Полювання (автовідповідь на CQ) diff --git a/ft8af/app/src/main/res/values-zh-rCN/strings_compose.xml b/ft8af/app/src/main/res/values-zh-rCN/strings_compose.xml index 50458feb6..7aaccc092 100644 --- a/ft8af/app/src/main/res/values-zh-rCN/strings_compose.xml +++ b/ft8af/app/src/main/res/values-zh-rCN/strings_compose.xml @@ -329,6 +329,9 @@ 超时后自动停止发射 停止条件 呼叫 N 次无应答后停止 + 73 发送上限 + 每次通联中 RR73/73 的最大发送次数,达到后转向下一个 + 自动 猎台(自动应答 CQ) diff --git a/ft8af/app/src/main/res/values-zh-rTW/strings_compose.xml b/ft8af/app/src/main/res/values-zh-rTW/strings_compose.xml index 967229a42..9834743d1 100644 --- a/ft8af/app/src/main/res/values-zh-rTW/strings_compose.xml +++ b/ft8af/app/src/main/res/values-zh-rTW/strings_compose.xml @@ -329,6 +329,9 @@ 逾時後自動停止發射 停止於 於 N 次無回應嘗試後停止呼叫 + 73 發送上限 + 每次通聯中 RR73/73 的最大發送次數,達到後轉往下一個 + 自動 獵取(自動回應 CQ) diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 70871661f..70bb70a98 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -577,6 +577,9 @@ Auto-stop transmit after timeout Stop After Stop calling after N unanswered attempts + Max 73 Sends + Maximum RR73/73 transmissions per contact before moving on + Auto Hunt (auto-answer CQ) diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/Max73SendsSequencerTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/Max73SendsSequencerTest.java new file mode 100644 index 000000000..b9140c1f7 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/Max73SendsSequencerTest.java @@ -0,0 +1,201 @@ +package com.k1af.ft8af.ft8transmit; + +import static com.google.common.truth.Truth.assertThat; + +import com.k1af.ft8af.Ft8Message; +import com.k1af.ft8af.GeneralVariables; +import com.k1af.ft8af.log.QSLRecord; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.ArrayList; + +/** + * Sequencer-level coverage for the "Max 73 Sends" cap + * ({@code GeneralVariables.max73Sends}) inside + * {@link FT8TransmitSignal#parseMessageToFunction(ArrayList, boolean)}: the cap + * must end the QSO in exactly the loops the no-reply caps cannot reach — + * a partner repeating RR73 at our 73 (each one re-triggers a 73 reply), or + * re-sending R+report at our RR73 (each decode resets the no-reply counter) — + * and must change nothing when set to Auto (0). + * + *

    Same Robolectric harness as {@link EvidenceOnlyParseTest}: the sequencer + * posts LiveData and logs through android.util.Log. + */ +@RunWith(RobolectricTestRunner.class) +public class Max73SendsSequencerTest { + + /** Slot-1 (odd) utcTime for FT8: (15000+750)/1000/15 == 1. */ + private static final long RX_SLOT_UTC = 15_000L; + + private FT8TransmitSignal signal; + + @Before + public void setUp() { + GeneralVariables.myCallsign = "K1AF"; + GeneralVariables.noReplyCount = 0; + GeneralVariables.noReplyLimit = 3; + GeneralVariables.max73Sends = 0; + GeneralVariables.houndMode = false; + GeneralVariables.autoFollowCQ = false; + GeneralVariables.autoCallFollow = false; + GeneralVariables.autoCQAfterQSO = false; + GeneralVariables.synFrequency = false; + GeneralVariables.qslRecordList.clear(); + GeneralVariables.transmitMessages.clear(); + GeneralVariables.addCallsignAndGrid("N2JFD", "FN20"); + signal = new FT8TransmitSignal(null, null, null); + } + + @After + public void tearDown() { + GeneralVariables.myCallsign = ""; + GeneralVariables.noReplyCount = 0; + GeneralVariables.max73Sends = 0; + GeneralVariables.qslRecordList.clear(); + GeneralVariables.callsignAndGrids.remove("N2JFD"); + } + + /** Put the sequencer mid-QSO with N2JFD at the given order (TX slot 0). */ + private void startQsoAtOrder(int order) { + QSLRecord record = new QSLRecord(0, 0, "K1AF", "EM28", "N2JFD", "FM09", + -17, -18, "FT8", GeneralVariables.band, 14074000); + GeneralVariables.qslRecordList.addQSLRecord(record); + signal.setTransmit(new TransmitCallsign(1, 0, "N2JFD", 1500f, 1, -17), order, ""); + } + + /** A decoded message in the partner's (odd) slot. */ + private static Ft8Message rxMsg(String to, String from, String extra) { + Ft8Message msg = new Ft8Message(1, 0, to, from, extra); + msg.utcTime = RX_SLOT_UTC; + msg.band = GeneralVariables.band; + msg.snr = -10; + return msg; + } + + private static ArrayList list(Ft8Message... msgs) { + ArrayList out = new ArrayList<>(); + for (Ft8Message m : msgs) out.add(m); + return out; + } + + // ---- the RR73→73 loop (partner never decodes our 73) --------------------- + + @Test + public void repeatedRR73_belowCap_stillReplies73() { + GeneralVariables.max73Sends = 3; + startQsoAtOrder(5); + signal.finalAckSends = 2;// two 73s already on the air + signal.parseMessageToFunction(list(rxMsg("K1AF", "N2JFD", "RR73")), false); + assertThat(signal.getFunctionOrder()).isEqualTo(5);// keep replying 73 + } + + @Test + public void repeatedRR73_atCap_endsQsoInsteadOfAnother73() { + // Without the cap this loop is unbounded: every received RR73 + // re-triggers a 73 reply, and noReplyCount resets on each decode. + GeneralVariables.max73Sends = 3; + startQsoAtOrder(5); + signal.finalAckSends = 3; + signal.parseMessageToFunction(list(rxMsg("K1AF", "N2JFD", "RR73")), false); + assertThat(signal.getFunctionOrder()).isEqualTo(6);// moved on to CQ + } + + // ---- the RR73 loop (partner never decodes our RR73) ---------------------- + + @Test + public void partnerResendsReportAtOurRR73_atCap_endsQso() { + // The partner keeps re-sending R+report, so newOrder is never -1 and + // the no-reply caps in shouldCompleteQso never fire. The send cap must. + GeneralVariables.max73Sends = 2; + startQsoAtOrder(4); + signal.finalAckSends = 2; + signal.parseMessageToFunction(list(rxMsg("K1AF", "N2JFD", "R-18")), false); + assertThat(signal.getFunctionOrder()).isEqualTo(6); + } + + @Test + public void partnerResendsReportAtOurRR73_belowCap_keepsRR73() { + GeneralVariables.max73Sends = 4; + startQsoAtOrder(4); + signal.finalAckSends = 2; + signal.parseMessageToFunction(list(rxMsg("K1AF", "N2JFD", "R-18")), false); + assertThat(signal.getFunctionOrder()).isEqualTo(4);// classic re-send + } + + // ---- deterministic own-send evidence applies on deep passes too ---------- + + @Test + public void deepPass_atCap_alsoEndsQso() { + GeneralVariables.max73Sends = 3; + startQsoAtOrder(5); + signal.finalAckSends = 3; + signal.parseMessageToFunction(list(rxMsg("K1AF", "N2JFD", "RR73")), true); + assertThat(signal.getFunctionOrder()).isEqualTo(6); + } + + // ---- Auto (0) keeps the classic behavior --------------------------------- + + @Test + public void autoSetting_repeatedRR73_keepsReplying73() { + GeneralVariables.max73Sends = 0; + startQsoAtOrder(5); + signal.finalAckSends = 50; + signal.parseMessageToFunction(list(rxMsg("K1AF", "N2JFD", "RR73")), false); + assertThat(signal.getFunctionOrder()).isEqualTo(5); + } + + // ---- the capped-station gate ---------------------------------------------- + + @Test + public void cappedStationRepeatsRR73_whileWeCQ_staysIgnored() { + GeneralVariables.max73Sends = 3; + startQsoAtOrder(5); + signal.finalAckSends = 3; + signal.parseMessageToFunction(list(rxMsg("K1AF", "N2JFD", "RR73")), false); + assertThat(signal.getFunctionOrder()).isEqualTo(6); + // Next cycle: the still-deaf partner repeats RR73 at our CQ. Without + // the gate this would re-answer them and resume the capped loop. + signal.parseMessageToFunction(list(rxMsg("K1AF", "N2JFD", "RR73")), false); + assertThat(signal.getFunctionOrder()).isEqualTo(6); + } + + @Test + public void cappedStationFreshGridCall_isAnsweredAndLiftsGate() { + GeneralVariables.max73Sends = 3; + startQsoAtOrder(5); + signal.finalAckSends = 3; + signal.parseMessageToFunction(list(rxMsg("K1AF", "N2JFD", "RR73")), false); + assertThat(signal.getFunctionOrder()).isEqualTo(6); + // A brand-new call from them (grid message) is a fresh QSO attempt: + // answer it, and the gate lifts for the new contact. + signal.parseMessageToFunction(list(rxMsg("K1AF", "N2JFD", "FN20")), false); + assertThat(signal.getFunctionOrder()).isEqualTo(2); + assertThat(signal.cappedCallsign).isEmpty(); + } + + // ---- counter lifecycle ---------------------------------------------------- + + @Test + public void newTarget_resetsCounter() { + startQsoAtOrder(4); + signal.finalAckSends = 3; + // A fresh QSO with a different station must not inherit the count. + signal.setTransmit(new TransmitCallsign(1, 0, "KN6KI", 1500f, 1, -17), 1, ""); + assertThat(signal.finalAckSends).isEqualTo(0); + } + + @Test + public void deactivate_resetsCounterAndGate() { + startQsoAtOrder(4); + signal.finalAckSends = 3; + signal.cappedCallsign = "N2JFD"; + signal.setActivated(false); + assertThat(signal.finalAckSends).isEqualTo(0); + assertThat(signal.cappedCallsign).isEmpty(); + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/Max73SendsTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/Max73SendsTest.java new file mode 100644 index 000000000..204b43104 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/Max73SendsTest.java @@ -0,0 +1,97 @@ +package com.k1af.ft8af.ft8transmit; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Coverage for {@link FT8TransmitSignal#hasReachedMax73Sends(int, int, int)} — + * the user-configurable hard cap on RR73/73 (order 4/5) transmissions per QSO. + * + *

    Unlike the no-reply caps in {@code shouldCompleteQso}, this cap counts + * actual sends, so it must also end the loops the no-reply counter never sees: + * a partner re-sending R+report at our RR73, or repeating RR73 at our 73 — + * both of which reset {@code noReplyCount} every cycle. + * + *

    Plain JUnit: the helper is a pure static predicate. + */ +public class Max73SendsTest { + + // ---- setting disabled (0 == Auto) --------------------------------------- + + @Test + public void autoSetting_neverCaps() { + // 0 = Auto: classic behavior only, no matter how many sends piled up. + assertThat(FT8TransmitSignal.hasReachedMax73Sends(0, 0, 4)).isFalse(); + assertThat(FT8TransmitSignal.hasReachedMax73Sends(0, 100, 4)).isFalse(); + assertThat(FT8TransmitSignal.hasReachedMax73Sends(0, 100, 5)).isFalse(); + } + + // ---- cap threshold ------------------------------------------------------- + + @Test + public void belowCap_keepsGoing() { + assertThat(FT8TransmitSignal.hasReachedMax73Sends(3, 0, 4)).isFalse(); + assertThat(FT8TransmitSignal.hasReachedMax73Sends(3, 2, 4)).isFalse(); + assertThat(FT8TransmitSignal.hasReachedMax73Sends(3, 2, 5)).isFalse(); + } + + @Test + public void atCap_completes() { + // Cap N means exactly N RR73/73 transmissions go out, then we move on. + assertThat(FT8TransmitSignal.hasReachedMax73Sends(3, 3, 4)).isTrue(); + assertThat(FT8TransmitSignal.hasReachedMax73Sends(3, 3, 5)).isTrue(); + assertThat(FT8TransmitSignal.hasReachedMax73Sends(1, 1, 4)).isTrue(); + } + + @Test + public void aboveCap_completes() { + // Counter can overshoot (e.g. cap lowered mid-QSO); still ends the QSO. + assertThat(FT8TransmitSignal.hasReachedMax73Sends(3, 5, 4)).isTrue(); + assertThat(FT8TransmitSignal.hasReachedMax73Sends(3, 5, 5)).isTrue(); + } + + // ---- only the final-ack states are capped -------------------------------- + + @Test + public void nonFinalAckOrders_neverCap() { + // Orders 1-3 (calling/report) are governed by the no-reply limit, and + // order 6 (CQ) must never be cut off by a stale counter. + for (int order : new int[]{1, 2, 3, 6}) { + assertThat(FT8TransmitSignal.hasReachedMax73Sends(3, 99, order)).isFalse(); + } + } + + @Test + public void negativeCounter_neverCaps() { + // Defensive: a wrapped/corrupt counter must not end QSOs spuriously. + assertThat(FT8TransmitSignal.hasReachedMax73Sends(3, -1, 4)).isFalse(); + } + + // ---- isCappedContinuation ------------------------------------------------- + + @Test + public void cappedContinuation_matchesOnlyContinuationOrdersFromCappedStation() { + // R+report (3) and RR73/RRR (4) from the capped station restart the + // capped loop and must be ignored. + assertThat(FT8TransmitSignal.isCappedContinuation("N2JFD", "N2JFD", 3)).isTrue(); + assertThat(FT8TransmitSignal.isCappedContinuation("N2JFD", "N2JFD", 4)).isTrue(); + // A fresh QSO attempt (grid/report) still deserves an answer. + assertThat(FT8TransmitSignal.isCappedContinuation("N2JFD", "N2JFD", 1)).isFalse(); + assertThat(FT8TransmitSignal.isCappedContinuation("N2JFD", "N2JFD", 2)).isFalse(); + // A fresh CQ from the capped station (order 6) is a new QSO attempt and + // must still be answered, not gated as a continuation. + assertThat(FT8TransmitSignal.isCappedContinuation("N2JFD", "N2JFD", 6)).isFalse(); + // An exact "73" (order 5) is a QSO end already filtered by the scans; it + // is not a continuation, so it is not gated here. + assertThat(FT8TransmitSignal.isCappedContinuation("N2JFD", "N2JFD", 5)).isFalse(); + // Other stations are never gated. + assertThat(FT8TransmitSignal.isCappedContinuation("N2JFD", "W1XYZ", 4)).isFalse(); + } + + @Test + public void cappedContinuation_noCappedStation_neverGates() { + assertThat(FT8TransmitSignal.isCappedContinuation("", "N2JFD", 4)).isFalse(); + assertThat(FT8TransmitSignal.isCappedContinuation(null, "N2JFD", 4)).isFalse(); + } +} From f7aca9625ae6cbb4c42130fb5b2d91913eb3be82 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 24 Jul 2026 10:30:51 -0500 Subject: [PATCH 089/113] Add a "signal reach" summary to the map's PSK Reporter "Heard me" overlay (#693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map already fetches PSK Reporter reception reports for the operator's own callsign and plots each receiver as a dot, but reading "how far is my signal actually getting out?" off a scattering of dots means panning and zooming. This adds a glanceable bottom card that answers it directly: - how many distinct stations heard us, - the furthest receiver (great-circle from our grid) + its callsign, - the receiver that copied us with the strongest SNR. The card appears only when the overlay is in the "Heard me" direction and no individual station is selected, so it never competes with the existing station-detail / filter sheets. It reuses the existing 5-minute PSK poll — no extra network traffic. The reduction is a pure, unit-tested helper (summarizeSignalReach): de-dupes reports by callsign (keeping the strongest), tolerates a missing operator grid (count + signal still shown, distance omitted), and skips blank callsigns. The composable is a thin renderer over it. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../radio/ks3ckc/ft8af/ui/map/MapScreen.kt | 82 +++++++++++++- .../radio/ks3ckc/ft8af/ui/map/SignalReach.kt | 104 ++++++++++++++++++ .../src/main/res/values/strings_compose.xml | 7 ++ .../ks3ckc/ft8af/ui/map/SignalReachTest.kt | 96 ++++++++++++++++ 4 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/SignalReach.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/SignalReachTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt index b1fcc3058..6b945bd12 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/MapScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -97,13 +98,13 @@ private data class StationMarker( // PSK Reporter overlay (issue #33) — the other station in a reception report // (a receiver that heard us, or a sender we heard, per the direction filter). private data class PskSpotMarker( - val callsign: String, + override val callsign: String, val grid: String, - val lat: Double, - val lon: Double, - val snr: Int, + override val lat: Double, + override val lon: Double, + override val snr: Int, override val frequencyHz: Long, -) : HasFrequencyHz +) : HasFrequencyHz, ReachSpot private const val PSK_POLL_INTERVAL_MS = 5L * 60L * 1000L @@ -553,7 +554,15 @@ fun MapScreen(mainViewModel: MainViewModel) { } // Bottom: the PSK filter sheet takes the bottom slot when open; otherwise the - // selected-station card. (Mutually exclusive so they don't stack.) + // selected-station card, or — when the "Heard me" overlay is on and nothing is + // selected — the signal-reach summary. (Mutually exclusive so they don't stack.) + val reach = remember(pskSpots, opLatLng, pskFilter.direction) { + if (pskFilter.direction == PskDirection.WHO_HEARD_ME) { + summarizeSignalReach(pskSpots, opLatLng?.latitude, opLatLng?.longitude) + } else { + null + } + } if (pskOverlayEnabled && filterSheetOpen) { PskFilterSheet( filter = pskFilter, @@ -575,6 +584,14 @@ fun MapScreen(mainViewModel: MainViewModel) { .padding(12.dp) .fillMaxWidth(), ) + } else if (pskOverlayEnabled && reach != null) { + SignalReachCard( + reach = reach, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(12.dp) + .fillMaxWidth(), + ) } } } @@ -1641,6 +1658,59 @@ private fun SelectedStationCard( } } +/** + * Bottom card summarising how far the operator's own signal is reaching, derived + * from PSK Reporter "heard me" spots by [summarizeSignalReach]. Shown when the + * overlay is on in the "Heard me" direction and no individual station is + * selected — the glanceable answer to "is my signal getting out, and how far?". + */ +@Composable +private fun SignalReachCard(reach: SignalReach, modifier: Modifier = Modifier) { + GlassCard(modifier = modifier) { + Column(modifier = Modifier.padding(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.map_reach_title), + color = Signal, + fontFamily = GeistMonoFamily, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + ) + Text( + text = pluralStringResource( + R.plurals.map_reach_receivers, + reach.receivers, + reach.receivers, + ), + color = TextMuted, + fontSize = 11.sp, + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + if (reach.furthestCall.isNotEmpty()) { + InfoChip( + MaidenheadGrid.formatDist(reach.furthestKm), + stringResource(R.string.map_reach_furthest, reach.furthestCall), + ) + } + if (reach.strongestCall.isNotEmpty()) { + InfoChip( + "${reach.strongestSnr} dB", + stringResource(R.string.map_reach_best, reach.strongestCall), + ) + } + } + } + } +} + @Composable private fun InfoChip(value: String, label: String) { Column(horizontalAlignment = Alignment.CenterHorizontally) { diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/SignalReach.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/SignalReach.kt new file mode 100644 index 000000000..14331b4bd --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/map/SignalReach.kt @@ -0,0 +1,104 @@ +package radio.ks3ckc.ft8af.ui.map + +/** + * "Signal reach" summary — the glanceable answer to the single most-asked FT8 + * question: *is my signal getting out, and how far?* + * + * The map already fetches PSK Reporter reception reports for the operator's own + * callsign (the "Heard me" direction) and plots each receiver as a dot. This + * pure helper reduces that same set of spots to a one-card headline: how many + * distinct stations heard us, the furthest one (great-circle from our grid), + * and the receiver that copied us with the strongest signal. + * + * Kept free of Compose/Android types so it can be unit-tested directly; the + * card composable in [MapScreen] is a thin renderer over [summarizeSignalReach]. + */ + +/** Minimal view of a reception report the reach summary needs. */ +internal interface ReachSpot { + val callsign: String + val lat: Double + val lon: Double + /** SNR (dB) at which this receiver decoded us; higher == copied us stronger. */ + val snr: Int +} + +/** + * Headline reach numbers. + * + * @property receivers distinct stations that heard us + * @property furthestKm great-circle distance to the furthest receiver (km); + * 0.0 when the operator grid is unknown (no distances) + * @property furthestCall callsign of the furthest receiver ("" when unknown) + * @property strongestSnr best SNR any receiver reported for us + * @property strongestCall callsign of that best-SNR receiver + */ +internal data class SignalReach( + val receivers: Int, + val furthestKm: Double, + val furthestCall: String, + val strongestSnr: Int, + val strongestCall: String, +) + +/** + * Summarise [spots] (receivers that heard us) into a [SignalReach], or null when + * there is nothing to report. + * + * Reports are de-duplicated by callsign — a single station often appears more + * than once (multiple decodes / bands in the window), and "12 stations heard + * you" should count stations, not lines. For a repeated callsign the strongest + * SNR and the (identical) location are kept. + * + * [opLat]/[opLon] are the operator's position; when null (no grid configured) + * distances can't be computed, so [SignalReach.furthestKm] is 0.0 and + * [SignalReach.furthestCall] is empty. The strongest-signal figures never depend + * on our own position, so they're always populated. + */ +internal fun summarizeSignalReach( + spots: List, + opLat: Double?, + opLon: Double?, +): SignalReach? { + if (spots.isEmpty()) return null + + // Collapse to one entry per callsign, keeping the strongest report we have. + val byCall = LinkedHashMap() + for (spot in spots) { + val key = spot.callsign.trim().uppercase() + if (key.isEmpty()) continue + val existing = byCall[key] + if (existing == null || spot.snr > existing.snr) { + byCall[key] = spot + } + } + if (byCall.isEmpty()) return null + + var furthestKm = 0.0 + var furthestCall = "" + var strongestSnr = Int.MIN_VALUE + var strongestCall = "" + + val haveOrigin = opLat != null && opLon != null + for ((call, spot) in byCall) { + if (haveOrigin) { + val km = greatCircleKm(opLat!!, opLon!!, spot.lat, spot.lon) + if (km > furthestKm) { + furthestKm = km + furthestCall = call + } + } + if (spot.snr > strongestSnr) { + strongestSnr = spot.snr + strongestCall = call + } + } + + return SignalReach( + receivers = byCall.size, + furthestKm = furthestKm, + furthestCall = furthestCall, + strongestSnr = strongestSnr, + strongestCall = strongestCall, + ) +} diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 70bb70a98..ae9817db5 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -409,6 +409,13 @@ Distance Bearing SNR + Your signal reach + + %1$d heard you + %1$d heard you + + Furthest · %1$s + Best · %1$s Waterfall NR MSG diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/SignalReachTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/SignalReachTest.kt new file mode 100644 index 000000000..f30b527c2 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/map/SignalReachTest.kt @@ -0,0 +1,96 @@ +package radio.ks3ckc.ft8af.ui.map + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Pure unit tests for [summarizeSignalReach] — the "how far is my signal + * getting?" headline derived from PSK Reporter "heard me" spots. No + * Android/Compose runtime is touched. + */ +class SignalReachTest { + + private data class Spot( + override val callsign: String, + override val lat: Double, + override val lon: Double, + override val snr: Int, + ) : ReachSpot + + // Operator sits roughly at Kansas (EM29-ish) for distance sanity checks. + private val opLat = 39.0 + private val opLon = -95.0 + + @Test + fun emptyList_returnsNull() { + assertThat(summarizeSignalReach(emptyList(), opLat, opLon)).isNull() + } + + @Test + fun countsReceivers_andPicksFurthest() { + val spots = listOf( + Spot("W1AW", 41.7, -72.7, -5), // Connecticut — close + Spot("G0ABC", 51.5, 0.0, -12), // England — ~7000 km + Spot("VK3XYZ", -37.8, 144.9, -18), // Australia — ~16000 km, furthest + ) + val reach = summarizeSignalReach(spots, opLat, opLon)!! + assertThat(reach.receivers).isEqualTo(3) + assertThat(reach.furthestCall).isEqualTo("VK3XYZ") + assertThat(reach.furthestKm).isGreaterThan(14000.0) + } + + @Test + fun picksStrongestSnr() { + val spots = listOf( + Spot("W1AW", 41.7, -72.7, -15), + Spot("K5AAA", 30.0, -97.0, -3), // strongest + Spot("G0ABC", 51.5, 0.0, -12), + ) + val reach = summarizeSignalReach(spots, opLat, opLon)!! + assertThat(reach.strongestCall).isEqualTo("K5AAA") + assertThat(reach.strongestSnr).isEqualTo(-3) + } + + @Test + fun deduplicatesByCallsign_keepingStrongestReport() { + // Same station heard us twice; count it once and keep the better SNR. + val spots = listOf( + Spot("W1AW", 41.7, -72.7, -18), + Spot("w1aw", 41.7, -72.7, -6), // lower-case + stronger + ) + val reach = summarizeSignalReach(spots, opLat, opLon)!! + assertThat(reach.receivers).isEqualTo(1) + assertThat(reach.strongestCall).isEqualTo("W1AW") + assertThat(reach.strongestSnr).isEqualTo(-6) + } + + @Test + fun noOperatorGrid_stillReportsCountAndSignal_butNoDistance() { + val spots = listOf( + Spot("W1AW", 41.7, -72.7, -5), + Spot("G0ABC", 51.5, 0.0, -12), + ) + val reach = summarizeSignalReach(spots, null, null)!! + assertThat(reach.receivers).isEqualTo(2) + assertThat(reach.strongestCall).isEqualTo("W1AW") + assertThat(reach.furthestKm).isEqualTo(0.0) + assertThat(reach.furthestCall).isEmpty() + } + + @Test + fun blankCallsigns_areIgnored() { + val spots = listOf( + Spot(" ", 41.7, -72.7, -5), + Spot("G0ABC", 51.5, 0.0, -12), + ) + val reach = summarizeSignalReach(spots, opLat, opLon)!! + assertThat(reach.receivers).isEqualTo(1) + assertThat(reach.strongestCall).isEqualTo("G0ABC") + } + + @Test + fun allBlankCallsigns_returnNull() { + val spots = listOf(Spot("", 41.7, -72.7, -5), Spot(" ", 51.5, 0.0, -12)) + assertThat(summarizeSignalReach(spots, opLat, opLon)).isNull() + } +} From 036037504f814071d82ae5d8f611ac890f0bea46 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 24 Jul 2026 10:31:04 -0500 Subject: [PATCH 090/113] Add a Worked All Continents (WAC) award to the logbook (#680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a Worked All Continents (WAC) award to the logbook WAC — a two-way contact with each of the six populated continents — is one of the oldest and most recognisable amateur-radio awards, yet the logbook tracked DXCC, CQ/ITU zones and grids but never continents. This adds real WAC tracking derived the same way the existing DXCC/zone stats are: each logged gridsquare is resolved through the DXCC lookup tables (grid -> DXCC entity -> continent). A new Stats-tab card shows the six continents as chips (worked ones highlighted, "N / 6" progress, a completion banner at 6/6), and the Awards tab gains a real WAC progress bar in place of nothing. - CountDbOpr.queryWorkedContinents(): synchronous grid->continent join, extracted so it is unit-testable against an in-memory DB; wrapped by a new getContinentCount() AsyncTask mirroring getDxcc(). - workedAllContinents(): pure Kotlin reducer turning raw continent codes into award progress (normalises case, dedupes, drops Antarctica/blanks/ junk so they can't inflate the total). - Tests: WorkedAllContinentsTest (pure JVM) and CountDbOprContinentTest (Robolectric + in-memory SQLite) covering the join and filtering. Co-Authored-By: Claude Opus 4.8 (1M context) * WAC: dedupe worked-continents query with SELECT DISTINCT + try-with-resources Clearer intent than GROUP BY, and the try-with-resources Cursor is guaranteed to close even if iteration throws. Behavior is unchanged; covered by the existing CountDbOprContinentTest. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/count/CountDbOpr.java | 69 ++++++++++ .../ks3ckc/ft8af/ui/logbook/LogbookScreen.kt | 120 ++++++++++++++++++ .../ft8af/ui/logbook/WorkedAllContinents.kt | 56 ++++++++ ft8af/app/src/main/res/values/strings.xml | 1 + .../src/main/res/values/strings_compose.xml | 7 + .../ft8af/count/CountDbOprContinentTest.java | 115 +++++++++++++++++ .../ui/logbook/WorkedAllContinentsTest.kt | 65 ++++++++++ 7 files changed, 433 insertions(+) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/WorkedAllContinents.kt create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/count/CountDbOprContinentTest.java create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/WorkedAllContinentsTest.kt diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/count/CountDbOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/count/CountDbOpr.java index ae5fb05e7..57839a534 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/count/CountDbOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/count/CountDbOpr.java @@ -52,6 +52,44 @@ public static void getDistanceCount(SQLiteDatabase db,AfterCount afterCount){ new DistanceCount(db,afterCount).execute(); } + /** + * Count the continents worked, for the "Worked All Continents" (WAC) award. + * Fires the callback once with one {@link CountValue} per worked continent + * (its {@code name} is the continent code, e.g. "EU"); the presentation logic + * lives in the pure Kotlin {@code workedAllContinents} reducer. + */ + public static void getContinentCount(SQLiteDatabase db,AfterCount afterCount){ + new GetContinentCount(db,afterCount).execute(); + } + + /** + * The distinct continent codes worked, derived from each log gridsquare via + * the DXCC lookup tables ({@code dxcc_grid} grid -> DXCC entity, then + * {@code dxccList} DXCC -> continent) exactly like the DXCC / CQ-zone stats. + * + *

    Extracted as a synchronous static method (separate from the AsyncTask) + * so the grid -> continent join is unit-testable against an in-memory + * database without touching the UI thread. Blank / null continents are + * filtered in SQL, so the returned list holds only usable codes (which may + * still include the non-WAC "AN"/"-" — the Kotlin reducer drops those). + * + * @return one continent code per group; empty when no logged grid resolves. + */ + @SuppressLint("Range") + public static java.util.List queryWorkedContinents(SQLiteDatabase db){ + java.util.ArrayList continents=new java.util.ArrayList<>(); + String querySQL="SELECT DISTINCT dl.continent AS cont FROM dxcc_grid dg\n" + + "inner join QSLTable q on dg.grid = UPPER(SUBSTR(q.gridsquare,1,4))\n" + + "left join dxccList dl on dg.dxcc = dl.dxcc\n" + + "WHERE dl.continent IS NOT NULL AND TRIM(dl.continent) <> ''"; + try (Cursor cursor=db.rawQuery(querySQL,null)){ + while (cursor.moveToNext()){ + continents.add(cursor.getString(cursor.getColumnIndex("cont"))); + } + } + return continents; + } + /** * Accumulated maximum/minimum great-circle distance (in km) from the * operator's own grid to a set of log gridsquares. {@link #hasData()} @@ -565,6 +603,37 @@ protected Void doInBackground(Void... voids) { + /** + * Gather the worked continents for the WAC award off the UI thread. The + * heavy lifting (the grid -> continent join) is in {@link #queryWorkedContinents} + * so this task is just the async wrapper + callback marshalling. + */ + static class GetContinentCount extends AsyncTask{ + private final SQLiteDatabase db; + private final AfterCount afterCount; + + public GetContinentCount(SQLiteDatabase db, AfterCount afterCount) { + this.db = db; + this.afterCount = afterCount; + } + + @Override + protected Void doInBackground(Void... voids) { + java.util.List continents = queryWorkedContinents(db); + ArrayList values = new ArrayList<>(); + for (String c : continents) { + values.add(new CountValue(1, c)); + } + if (afterCount != null) { + afterCount.countInformation(new CountInfo("" + , ChartType.Bar + , GeneralVariables.getStringFromResource(R.string.continent_completion_statistics) + , values)); + } + return null; + } + } + public interface AfterCount{ void countInformation(CountInfo countInfo); } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt index ce4ba4772..27dbf1d43 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt @@ -139,6 +139,9 @@ private data class LogbookStats( val cqZones: Int = 0, val ituZones: Int = 0, val bandCounts: List> = emptyList(), + // Raw continent codes worked (e.g. "NA", "EU"), for the WAC award. Reduced + // to award progress by [workedAllContinents]; empty when nothing resolves. + val continentCodes: List = emptyList(), // Worked All States (WAS): distinct US states worked (0..50) and the // canonically-ordered list of states still needed. Derived from each QSO's // grid via UsStateLookup — see WorkedAllStates.kt. @@ -258,6 +261,17 @@ fun LogbookScreen(mainViewModel: MainViewModel) { val bandCounts = bandInfo?.values?.map { (it.name ?: "") to it.value } ?: emptyList() + // Worked continents for the WAC award (single-fire callback). + val continentInfo = suspendCancellableCoroutine { cont -> + val resumed = AtomicBoolean(false) + CountDbOpr.getContinentCount(db) { info -> + if (resumed.compareAndSet(false, true)) cont.resume(info) + } + } + val continentCodes = continentInfo?.values + ?.mapNotNull { it.name } + ?: emptyList() + // Worked All States: resolve each QSO's grid to a US state and // collapse to the distinct set (see WorkedAllStates.kt). val worked = workedStates(loaded.map { it.grid }) { @@ -270,6 +284,7 @@ fun LogbookScreen(mainViewModel: MainViewModel) { cqZones = cqCount, ituZones = ituCount, bandCounts = bandCounts, + continentCodes = continentCodes, statesWorked = worked.size, neededStates = neededStates(worked), ) @@ -674,6 +689,13 @@ private fun StatsTab(stats: LogbookStats, records: List) { BestDxCard(bestDx = bestDx, modifier = Modifier.fillMaxWidth()) } + // Worked All Continents — the six-continent award, one chip per continent. + SectionHeader(stringResource(R.string.log_section_wac)) + WorkedAllContinentsCard( + wac = remember(stats.continentCodes) { workedAllContinents(stats.continentCodes) }, + modifier = Modifier.fillMaxWidth(), + ) + // Band donut chart if (stats.bandCounts.isNotEmpty()) { SectionHeader(stringResource(R.string.log_section_band_distribution)) @@ -815,6 +837,94 @@ private fun BestDxCard(bestDx: BestDx, modifier: Modifier = Modifier) { } } +// --------------------------------------------------------------------------- +// Worked All Continents (WAC) card +// --------------------------------------------------------------------------- + +/** Localized full name for a continent code, for chip accessibility labels. */ +@Composable +private fun continentName(code: String): String = when (code) { + "NA" -> stringResource(R.string.continent_na) + "SA" -> stringResource(R.string.continent_sa) + "EU" -> stringResource(R.string.continent_eu) + "AF" -> stringResource(R.string.continent_af) + "AS" -> stringResource(R.string.continent_as) + "OC" -> stringResource(R.string.continent_oc) + else -> code +} + +@Composable +private fun WorkedAllContinentsCard(wac: WacProgress, modifier: Modifier = Modifier) { + GlassCard(modifier = modifier) { + Column(modifier = Modifier.padding(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (wac.isComplete) { + stringResource(R.string.log_wac_complete) + } else { + stringResource(R.string.log_wac_progress, wac.workedCount, wac.total) + }, + color = if (wac.isComplete) StatusConfirmed else TextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "${wac.workedCount} / ${wac.total}", + color = TextMuted, + fontSize = 11.sp, + fontFamily = GeistMonoFamily, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + for (chip in wac.chips) { + ContinentChipView(chip = chip, modifier = Modifier.weight(1f)) + } + } + } + } +} + +@Composable +private fun ContinentChipView(chip: ContinentChip, modifier: Modifier = Modifier) { + val name = continentName(chip.code) + val label = if (chip.worked) { + stringResource(R.string.log_wac_continent_worked, name) + } else { + stringResource(R.string.log_wac_continent_needed, name) + } + Box( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(if (chip.worked) StatusNew.copy(alpha = 0.16f) else BgSurface3) + .border( + 1.dp, + if (chip.worked) StatusNew.copy(alpha = 0.5f) else Border, + RoundedCornerShape(8.dp), + ) + .padding(vertical = 8.dp) + .semantics { contentDescription = label }, + contentAlignment = Alignment.Center, + ) { + Text( + text = chip.code, + color = if (chip.worked) StatusNew else TextDim, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = GeistMonoFamily, + ) + } +} + // --------------------------------------------------------------------------- // Section header // --------------------------------------------------------------------------- @@ -1724,9 +1834,12 @@ private fun AwardsTab(stats: LogbookStats) { val wazDesc = stringResource(R.string.log_award_waz_desc) val vuccName = stringResource(R.string.log_award_vucc) val vuccDesc = stringResource(R.string.log_award_vucc_desc) + val wacName = stringResource(R.string.log_award_wac) + val wacDesc = stringResource(R.string.log_award_wac_desc) val iotaName = stringResource(R.string.log_award_iota) val iotaDesc = stringResource(R.string.log_award_iota_desc) val awards = remember(stats) { + val wac = workedAllContinents(stats.continentCodes) listOf( AwardProgress( name = dxccMixedName, @@ -1750,6 +1863,13 @@ private fun AwardsTab(stats: LogbookStats) { total = 40, color = StatusNew, ), + AwardProgress( + name = wacName, + description = wacDesc, + current = wac.workedCount, + total = wac.total, + color = Band10m, + ), AwardProgress( name = vuccName, description = vuccDesc, diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/WorkedAllContinents.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/WorkedAllContinents.kt new file mode 100644 index 000000000..7a2b3b64d --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/WorkedAllContinents.kt @@ -0,0 +1,56 @@ +package radio.ks3ckc.ft8af.ui.logbook + +/** + * Pure presentation logic for the "Worked All Continents" (WAC) award shown on + * the logbook Stats tab. Kept as plain top-level functions/data (no Compose) so + * the award reducer is unit-testable on the JVM without Robolectric — the + * Composable card is a thin wrapper that just renders [workedAllContinents]. + * + * WAC is one of the oldest and most recognisable amateur-radio awards: confirm a + * two-way contact with each of the six populated continents. Antarctica ("AN") + * is an optional endorsement, not one of the six required, so it never counts + * toward the award total here. + * + * The raw continent codes come from the DXCC lookup tables via + * [com.k1af.ft8af.count.CountDbOpr.queryWorkedContinents] (grid -> DXCC entity -> + * continent), matching how the DXCC / CQ-zone stats are already derived. + */ + +/** The six WAC continents, in a conventional display order. */ +internal val WAC_CONTINENTS: List = listOf("NA", "SA", "EU", "AF", "AS", "OC") + +/** One continent's award state: its code (e.g. "EU") and whether it's worked. */ +internal data class ContinentChip(val code: String, val worked: Boolean) + +/** Reduced WAC award progress: a chip per required continent plus a worked tally. */ +internal data class WacProgress( + val chips: List, + val workedCount: Int, +) { + /** Number of continents required for the award (always 6). */ + val total: Int get() = WAC_CONTINENTS.size + + /** True once every required continent has been worked. */ + val isComplete: Boolean get() = workedCount >= WAC_CONTINENTS.size +} + +/** + * Reduce a collection of raw continent codes (as stored in `dxccList.continent`, + * e.g. "NA", "EU", or the stray "AN"/"-") into [WacProgress]. + * + * Codes are trimmed and upper-cased before matching, duplicates collapse, and + * anything outside the six standard continents (Antarctica, blanks, unknown "-", + * junk) is ignored so it can never inflate the award total. The returned chip + * list is always the six [WAC_CONTINENTS] in canonical order, so the UI renders a + * stable row regardless of which continents happen to be present in the input. + */ +internal fun workedAllContinents(rawCodes: Collection): WacProgress { + val worked = rawCodes + .asSequence() + .filterNotNull() + .map { it.trim().uppercase() } + .filter { it in WAC_CONTINENTS } + .toSet() + val chips = WAC_CONTINENTS.map { ContinentChip(code = it, worked = it in worked) } + return WacProgress(chips = chips, workedCount = worked.size) +} diff --git a/ft8af/app/src/main/res/values/strings.xml b/ft8af/app/src/main/res/values/strings.xml index 747a3f769..45c380170 100644 --- a/ft8af/app/src/main/res/values/strings.xml +++ b/ft8af/app/src/main/res/values/strings.xml @@ -318,6 +318,7 @@ Total CQ Zone : %d \n Completed : %d(%.1f%%) CQ Zone Statistics DXCC statistics + Continent statistics Total DXCC : %d \nCompleted : %d(%.1f%%) DXCC statistics Callsign:%s\nLocation:%s\nCQ zone:%d\nITU zone:%d\nContinent:%s\nLongitude and latitude:%.2f,%.2f\nTime zone:%.0f\nDxcc prefix:%s diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index ae9817db5..12f056b79 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -270,6 +270,11 @@ Worked All States STATES NEEDED Best DX + Worked All Continents + %1$d / %2$d continents + All six continents worked 🌍 + %1$s worked + %1$s not yet worked Grid Coverage Signal Trend No QSOs yet @@ -289,6 +294,8 @@ Work all 40 CQ zones confirmed VUCC VHF/UHF Century Club -- 100 grid squares on a single band + WAC + Worked All Continents -- contact all six continents IOTA Islands on the Air -- work stations on designated islands Edit QSO diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/count/CountDbOprContinentTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/count/CountDbOprContinentTest.java new file mode 100644 index 000000000..cbb0d8b28 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/count/CountDbOprContinentTest.java @@ -0,0 +1,115 @@ +package com.k1af.ft8af.count; + +import static com.google.common.truth.Truth.assertThat; + +import android.database.sqlite.SQLiteDatabase; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.List; + +/** + * Exercises {@link CountDbOpr#queryWorkedContinents} — the grid -> DXCC entity -> + * continent join behind the "Worked All Continents" (WAC) award. Uses an + * in-memory SQLite database mirroring the three tables the query touches + * ({@code dxcc_grid}, {@code dxccList}, {@code QSLTable}); Robolectric provides + * real SQLite. + */ +@RunWith(RobolectricTestRunner.class) +public class CountDbOprContinentTest { + + private SQLiteDatabase db; + + @Before + public void setUp() { + db = SQLiteDatabase.create(null); + db.execSQL("CREATE TABLE QSLTable (id INTEGER PRIMARY KEY, gridsquare TEXT)"); + db.execSQL("CREATE TABLE dxcc_grid (dxcc INTEGER, grid TEXT)"); + db.execSQL("CREATE TABLE dxccList (dxcc INTEGER, continent TEXT)"); + + // DXCC entities -> continent (one blank + Antarctica to exercise filtering). + insertDxcc(1, "EU"); + insertDxcc(2, "NA"); + insertDxcc(3, "AN"); // Antarctica: returned raw, dropped later by the reducer + insertDxcc(4, ""); // blank continent: filtered out in SQL + + // 4-char grid -> DXCC entity. + insertGrid(1, "IO91"); + insertGrid(2, "FN20"); + insertGrid(3, "AA00"); + insertGrid(4, "BB11"); + } + + @After + public void tearDown() { + if (db != null) { + db.close(); + } + } + + private void insertDxcc(int dxcc, String continent) { + db.execSQL("INSERT INTO dxccList (dxcc, continent) VALUES (?, ?)", + new Object[]{dxcc, continent}); + } + + private void insertGrid(int dxcc, String grid) { + db.execSQL("INSERT INTO dxcc_grid (dxcc, grid) VALUES (?, ?)", + new Object[]{dxcc, grid}); + } + + private void logQso(String gridsquare) { + db.execSQL("INSERT INTO QSLTable (gridsquare) VALUES (?)", new Object[]{gridsquare}); + } + + @Test + public void resolvesWorkedContinents_fromSixCharGrids() { + logQso("IO91np"); // EU + logQso("FN20xr"); // NA + + List continents = CountDbOpr.queryWorkedContinents(db); + + assertThat(continents).containsExactly("EU", "NA"); + } + + @Test + public void matchIsCaseInsensitive_onTheGridPrefix() { + // Operators frequently log the locator lower-cased; the query upper-cases + // the 4-char prefix before joining dxcc_grid. + logQso("aa00"); // -> AA00 -> AN + + List continents = CountDbOpr.queryWorkedContinents(db); + + assertThat(continents).containsExactly("AN"); + } + + @Test + public void blankContinentAndUnmatchedGrids_areExcluded() { + logQso("BB11aa"); // maps to a DXCC whose continent is blank -> excluded + logQso("ZZ99zz"); // no dxcc_grid row -> dropped by the inner join + + List continents = CountDbOpr.queryWorkedContinents(db); + + assertThat(continents).isEmpty(); + } + + @Test + public void deduplicatesContinents_acrossManyContacts() { + logQso("IO91aa"); // EU + logQso("IO91bb"); // EU (same continent, different sub-square) + logQso("FN20cc"); // NA + + List continents = CountDbOpr.queryWorkedContinents(db); + + // GROUP BY continent collapses the duplicate EU contacts to one row. + assertThat(continents).containsExactly("EU", "NA"); + } + + @Test + public void emptyLog_returnsEmptyList() { + assertThat(CountDbOpr.queryWorkedContinents(db)).isEmpty(); + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/WorkedAllContinentsTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/WorkedAllContinentsTest.kt new file mode 100644 index 000000000..7bc39f0bd --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/WorkedAllContinentsTest.kt @@ -0,0 +1,65 @@ +package radio.ks3ckc.ft8af.ui.logbook + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [workedAllContinents] — the pure reducer behind the logbook + * "Worked All Continents" (WAC) award card and Awards-tab progress bar. Runs on + * the plain JVM: the reducer takes raw continent codes (as produced by + * [com.k1af.ft8af.count.CountDbOpr.queryWorkedContinents]) and never touches + * Android/DB types. + */ +class WorkedAllContinentsTest { + + @Test + fun emptyInput_isZeroOfSix_notComplete() { + val wac = workedAllContinents(emptyList()) + assertThat(wac.workedCount).isEqualTo(0) + assertThat(wac.total).isEqualTo(6) + assertThat(wac.isComplete).isFalse() + // Always renders all six chips, in canonical order, none worked. + assertThat(wac.chips.map { it.code }).containsExactlyElementsIn(WAC_CONTINENTS).inOrder() + assertThat(wac.chips.none { it.worked }).isTrue() + } + + @Test + fun marksOnlyTheWorkedContinents() { + val wac = workedAllContinents(listOf("NA", "EU", "AS")) + assertThat(wac.workedCount).isEqualTo(3) + assertThat(wac.isComplete).isFalse() + val worked = wac.chips.filter { it.worked }.map { it.code }.toSet() + assertThat(worked).containsExactly("NA", "EU", "AS") + } + + @Test + fun allSixWorked_isComplete() { + val wac = workedAllContinents(listOf("NA", "SA", "EU", "AF", "AS", "OC")) + assertThat(wac.workedCount).isEqualTo(6) + assertThat(wac.isComplete).isTrue() + assertThat(wac.chips.all { it.worked }).isTrue() + } + + @Test + fun duplicatesCollapse_countIsDistinct() { + val wac = workedAllContinents(listOf("EU", "EU", "EU", "NA")) + assertThat(wac.workedCount).isEqualTo(2) + } + + @Test + fun normalizesCaseAndWhitespace() { + val wac = workedAllContinents(listOf(" na ", "eu", "As")) + assertThat(wac.chips.filter { it.worked }.map { it.code }.toSet()) + .containsExactly("NA", "EU", "AS") + } + + @Test + fun antarcticaBlanksAndJunk_areIgnored() { + // "AN" (Antarctica) is an endorsement, not one of the six required; "-", + // blanks, nulls and unrelated tokens must never inflate the award. + val wac = workedAllContinents(listOf("AN", "-", "", " ", null, "ZZ", "NA")) + assertThat(wac.workedCount).isEqualTo(1) + assertThat(wac.chips.single { it.worked }.code).isEqualTo("NA") + assertThat(wac.chips.map { it.code }).doesNotContain("AN") + } +} From 2d955cc367ec71961b0e4a2b6dffeaf0ef5e2029 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 28 Jul 2026 09:12:51 -0500 Subject: [PATCH 091/113] Fix non-portable ADIF export fields and surface upload failure reasons (#701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three problems found while diagnosing a logbook server that had silently rejected every QSO upload for three days. **Zero-length fields.** An optional value that was present-but-empty (a QSO where the other station never sent a grid) exported as ` `. Several ADIF importers treat a length-0 field as malformed and reject the whole record rather than reading it as "absent" — 39 of 384 records in a real export carried one. Empty now means omitted, which every parser agrees on. `mode` had its own null-only guard and so kept emitting `` even after the shared helper was fixed; the new test caught it. `comment` was emitted unconditionally because the `` terminator was glued onto it — they are separate now. **QSL_MANUAL is not an ADIF field.** The bare name is non-conformant; ADIF reserves `APP__` for program-specific data. We now write `APP_FT8AF_QSL_MANUAL`, and `QSLRecord` reads both names so files exported by older builds still round-trip with their confirmation flag intact. **Upload failures were invisible.** `uploadAdifToCloudlog` returned a bare boolean and the server's explanation went only to `Log.d`, so `debug.log` recorded `cloudlog=0 qrz=0 of 113` — indistinguishable from having nothing to upload. The reason now flows through `SyncResult` into the log line, which would have read: QsoAutoSync: done (app-start): cloudlog=0 qrz=0 of 113 cloudlogError=HTTP 400: ... column "tx_pwr" of relation "contacts" does not exist That is a 30-second diagnosis instead of a three-day silent backlog. Also collapses `DatabaseOpr.downQSLTable` — the built-in web logbook's ADIF download — onto `AdifRecord`. It was a line-for-line duplicate of `AdifRecord.build()` and had drifted: it still carried both bugs above long after the file-export path was fixed. One builder now, so the next formatting fix can't land in only one of them (-95 lines). Tests: zero-length omission across every optional field, the APP_ prefix and legacy-name import round-trip, and the failure-reason formatting (status + server body, newline collapsing, truncation, and never echoing the submitted ADIF into debug.log). 2881 pass. Co-authored-by: Claude Opus 5 (1M context) --- .../com/k1af/ft8af/database/DatabaseOpr.java | 178 +++++------------- .../java/com/k1af/ft8af/log/AdifRecord.java | 76 +++++--- .../java/com/k1af/ft8af/log/QSLRecord.java | 13 +- .../com/k1af/ft8af/log/ThirdPartyService.java | 161 ++++++++++++++-- .../radio/ks3ckc/ft8af/sync/QsoAutoSync.kt | 20 +- .../database/DatabaseOprAdifLengthTest.java | 41 +++- .../com/k1af/ft8af/log/AdifRecordTest.java | 53 +++++- .../com/k1af/ft8af/log/QSLRecordTest.java | 47 +++++ .../ThirdPartyServiceFailureReasonTest.java | 100 ++++++++++ .../ft8af/sync/QsoAutoSyncSummaryTest.kt | 78 ++++++++ 10 files changed, 581 insertions(+), 186 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/log/ThirdPartyServiceFailureReasonTest.java create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/sync/QsoAutoSyncSummaryTest.kt diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 6e79ee60e..64e05b65f 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -30,6 +30,7 @@ import com.k1af.ft8af.connector.ConnectMode; import com.k1af.ft8af.ft8signal.FT8Package; import com.k1af.ft8af.log.AdifFormat; +import com.k1af.ft8af.log.AdifRecord; import com.k1af.ft8af.log.OnQueryQSLCallsign; import com.k1af.ft8af.log.OnQueryQSLRecordCallsign; import com.k1af.ft8af.log.QSLCallsignRecord; @@ -1240,144 +1241,63 @@ public void getCallsignQTH(String callsign) { public String downQSLTable(Cursor cursor, boolean isSWL) { StringBuilder logStr = new StringBuilder(); - logStr.append("FT8AF ADIF Export\n"); + logStr.append(AdifRecord.HEADER); cursor.moveToPosition(-1); while (cursor.moveToNext()) { - logStr.append(com.k1af.ft8af.log.AdifFormat.callField( - cursor.getString(cursor.getColumnIndex("call")))); - if (!isSWL) { - if (cursor.getInt(cursor.getColumnIndex("isLotW_QSL")) == 1) { - logStr.append("Y "); - } else { - logStr.append("N "); - } - if (cursor.getInt(cursor.getColumnIndex("isQSL")) == 1) { - logStr.append("Y "); - } else { - logStr.append("N "); - } - } else { - logStr.append("Y "); - } - - if (cursor.getString(cursor.getColumnIndex("gridsquare")) != null) { - logStr.append(String.format(Locale.US, "%s " - , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("gridsquare"))) - , cursor.getString(cursor.getColumnIndex("gridsquare")))); - } - - if (cursor.getString(cursor.getColumnIndex("mode")) != null) { - // FT4/FT2 are ADIF submodes of MFSK, not standalone modes — a bare - // FT2 is rejected as invalid by pota.app and other ADIF consumers. - String mode = cursor.getString(cursor.getColumnIndex("mode")); - String submode = AdifFormat.mfskSubmode(mode); - if (submode != null) { - logStr.append(String.format(Locale.US, "MFSK %s " - , AdifFormat.utf8Length(submode), submode)); - } else { - logStr.append(String.format(Locale.US, "%s " - , AdifFormat.utf8Length(mode), mode)); - } - } - - if (cursor.getString(cursor.getColumnIndex("rst_sent")) != null) { - logStr.append(String.format(Locale.US, "%s " - , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("rst_sent"))) - , cursor.getString(cursor.getColumnIndex("rst_sent")))); - } - - if (cursor.getString(cursor.getColumnIndex("rst_rcvd")) != null) { - logStr.append(String.format(Locale.US, "%s " - , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("rst_rcvd"))) - , cursor.getString(cursor.getColumnIndex("rst_rcvd")))); - } - - if (cursor.getString(cursor.getColumnIndex("qso_date")) != null) { - logStr.append(String.format(Locale.US, "%s " - , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("qso_date"))) - , cursor.getString(cursor.getColumnIndex("qso_date")))); - } - - if (cursor.getString(cursor.getColumnIndex("time_on")) != null) { - logStr.append(String.format(Locale.US, "%s " - , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("time_on"))) - , cursor.getString(cursor.getColumnIndex("time_on")))); - } - - if (cursor.getString(cursor.getColumnIndex("qso_date_off")) != null) { - logStr.append(String.format(Locale.US, "%s " - , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("qso_date_off"))) - , cursor.getString(cursor.getColumnIndex("qso_date_off")))); - } - - if (cursor.getString(cursor.getColumnIndex("time_off")) != null) { - logStr.append(String.format(Locale.US, "%s " - , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("time_off"))) - , cursor.getString(cursor.getColumnIndex("time_off")))); - } - - if (cursor.getString(cursor.getColumnIndex("band")) != null) { - logStr.append(String.format(Locale.US, "%s " - , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("band"))) - , cursor.getString(cursor.getColumnIndex("band")))); - } - - if (cursor.getString(cursor.getColumnIndex("freq")) != null) { - logStr.append(String.format(Locale.US, "%s " - , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("freq"))) - , cursor.getString(cursor.getColumnIndex("freq")))); - } - - if (cursor.getString(cursor.getColumnIndex("station_callsign")) != null) { - logStr.append(String.format(Locale.US, "%s " - , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("station_callsign"))) - , cursor.getString(cursor.getColumnIndex("station_callsign")))); - } - - if (cursor.getString(cursor.getColumnIndex("my_gridsquare")) != null) { - logStr.append(String.format(Locale.US, "%s " - , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("my_gridsquare"))) - , cursor.getString(cursor.getColumnIndex("my_gridsquare")))); - } - - if (cursor.getColumnIndex("operator") != -1) { - if (cursor.getString(cursor.getColumnIndex("operator")) != null) { - logStr.append(String.format(Locale.US, "%s " - , AdifFormat.utf8Length(cursor.getString(cursor.getColumnIndex("operator"))) - , cursor.getString(cursor.getColumnIndex("operator")))); - } - } - - // POTA ADIF fields — emitted only when populated so non-POTA rows - // remain byte-identical to the prior upload format. - appendPotaField(logStr, cursor, "my_sig", "MY_SIG"); - appendPotaField(logStr, cursor, "my_sig_info", "MY_SIG_INFO"); - appendPotaField(logStr, cursor, "sig", "SIG"); - appendPotaField(logStr, cursor, "sig_info", "SIG_INFO"); - - String comment = cursor.getString(cursor.getColumnIndex("comment")); - if (comment == null) { - comment = ""; - } - - //Distance: 99 km - //When writing to db, must append " km" - logStr.append(String.format(Locale.US, "%s \n" - , AdifFormat.utf8Length(comment) - , comment)); + logStr.append(adifRecordFromCursor(cursor, isSWL).build()); } cursor.close(); return logStr.toString(); } - /** Append a POTA ADIF field if the column exists and is non-empty. */ - private static void appendPotaField(StringBuilder sb, Cursor cursor, String column, String adifName) { + /** + * Map one QSLTable cursor row onto the shared {@link AdifRecord} builder. + * + *

    This used to hand-roll the whole record inline, duplicating + * {@link AdifRecord#build()} field for field — and the two copies drifted. Routing + * both through the one builder means a formatting fix lands in every export at once + * instead of only in whichever twin someone remembered to edit. + * + *

    {@code operator} and the POTA columns are read defensively: this cursor arrives + * from several different queries and not all of them select those columns. + */ + static AdifRecord adifRecordFromCursor(Cursor cursor, boolean isSWL) { + return new AdifRecord() + .call(colStr(cursor, "call")) + .swl(isSWL) + .lotwQsl(colInt(cursor, "isLotW_QSL") == 1) + .manualQsl(colInt(cursor, "isQSL") == 1) + .gridsquare(colStr(cursor, "gridsquare")) + .mode(colStr(cursor, "mode")) + .rstSent(colStr(cursor, "rst_sent")) + .rstRcvd(colStr(cursor, "rst_rcvd")) + .qsoDate(colStr(cursor, "qso_date")) + .timeOn(colStr(cursor, "time_on")) + .qsoDateOff(colStr(cursor, "qso_date_off")) + .timeOff(colStr(cursor, "time_off")) + .band(colStr(cursor, "band")) + .freq(colStr(cursor, "freq")) + .stationCallsign(colStr(cursor, "station_callsign")) + .myGridsquare(colStr(cursor, "my_gridsquare")) + .operator(colStr(cursor, "operator")) + .mySig(colStr(cursor, "my_sig")) + .mySigInfo(colStr(cursor, "my_sig_info")) + .sig(colStr(cursor, "sig")) + .sigInfo(colStr(cursor, "sig_info")) + .comment(colStr(cursor, "comment")); + } + + /** Cursor string read that tolerates the column being absent from this query. */ + private static String colStr(Cursor cursor, String column) { + int idx = cursor.getColumnIndex(column); + return idx < 0 ? null : cursor.getString(idx); + } + + /** Cursor int read that tolerates the column being absent from this query. */ + private static int colInt(Cursor cursor, String column) { int idx = cursor.getColumnIndex(column); - if (idx < 0) return; - String value = cursor.getString(idx); - if (value == null || value.isEmpty()) return; - sb.append(String.format(Locale.US, "<%s:%d>%s ", adifName, AdifFormat.utf8Length(value), value)); + return idx < 0 ? 0 : cursor.getInt(idx); } /** diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifRecord.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifRecord.java index 4c18e3a17..2361d016c 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifRecord.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifRecord.java @@ -22,17 +22,29 @@ * value, not UTF-16 chars, so non-ASCII content (comments, park names) stays aligned. * * - *

    Fields left {@code null} are omitted entirely; a field set to {@code ""} is still - * emitted (as a zero-length field) to match the historical export behaviour. The POTA - * fields ({@code MY_SIG}, {@code MY_SIG_INFO}, {@code SIG}, {@code SIG_INFO}) are the - * exception: they are emitted only when non-null and non-empty so ordinary - * non-POTA contacts stay clean. + *

    Fields that are {@code null} or empty are omitted entirely. Emitting a + * zero-length field ({@code }) instead — as this class used to for empty + * strings — is not portable: several ADIF importers treat a length-0 field as malformed + * and reject the whole record rather than reading it as "absent". An absent optional + * field means the same thing to every parser, so that is what we write. */ public final class AdifRecord { /** Header (with {@code }) written once at the top of a fresh ADIF file. */ public static final String HEADER = "FT8AF ADIF Export\n"; + /** + * Application-defined field carrying the "manually confirmed" flag. ADIF has no + * {@code QSL_MANUAL} field — that bare name (which FT8AF wrote until this change) + * is non-conformant. {@code APP__} is the spec's escape hatch for + * program-specific data. {@link QSLRecord} still reads the legacy bare name so ADIF + * files exported by older builds keep round-tripping. + */ + public static final String APP_QSL_MANUAL = "APP_FT8AF_QSL_MANUAL"; + + /** Legacy, non-conformant name for {@link #APP_QSL_MANUAL}. Read-only: never emitted. */ + public static final String LEGACY_QSL_MANUAL = "QSL_MANUAL"; + private String call; private boolean swl; private boolean lotwQsl; @@ -58,7 +70,7 @@ public final class AdifRecord { public AdifRecord call(String v) { this.call = v; return this; } - /** SWL record: emits {@code Y } instead of the QSL_RCVD/QSL_MANUAL pair. */ + /** SWL record: emits {@code Y } instead of the QSL_RCVD/APP_FT8AF_QSL_MANUAL pair. */ public AdifRecord swl(boolean v) { this.swl = v; return this; } public AdifRecord lotwQsl(boolean v) { this.lotwQsl = v; return this; } @@ -112,10 +124,18 @@ public String build() { sb.append("Y "); } else { sb.append(lotwQsl ? "Y " : "N "); - sb.append(manualQsl ? "Y " : "N "); + // QSL_MANUAL is not an ADIF field. The spec reserves the APP__ + // prefix for application-defined fields, so a strict importer can skip ours + // instead of erroring on an unrecognised bare name. QSL_RCVD above *is* + // standard and keeps its name. + sb.append(manualQsl + ? "<" + APP_QSL_MANUAL + ":1>Y " + : "<" + APP_QSL_MANUAL + ":1>N "); } - appendIfNotNull(sb, "gridsquare", gridsquare); - if (mode != null) { + appendIfNotEmpty(sb, "gridsquare", gridsquare); + // Empty as well as null: mode has its own branch (for the MFSK submode split) and + // so bypasses appendIfNotEmpty, which is exactly how it kept emitting . + if (mode != null && !mode.isEmpty()) { // FT4/FT2 are ADIF submodes of MFSK, not standalone modes — a bare FT2 is // rejected as invalid by pota.app and other ADIF consumers. String submode = AdifFormat.mfskSubmode(mode); @@ -126,24 +146,24 @@ public String build() { appendField(sb, "mode", mode); } } - appendIfNotNull(sb, "rst_sent", rstSent); - appendIfNotNull(sb, "rst_rcvd", rstRcvd); - appendIfNotNull(sb, "qso_date", qsoDate); - appendIfNotNull(sb, "time_on", timeOn); - appendIfNotNull(sb, "qso_date_off", qsoDateOff); - appendIfNotNull(sb, "time_off", timeOff); - appendIfNotNull(sb, "band", band); - appendIfNotNull(sb, "freq", freq); - appendIfNotNull(sb, "station_callsign", stationCallsign); - appendIfNotNull(sb, "my_gridsquare", myGridsquare); - appendIfNotNull(sb, "operator", operator); + appendIfNotEmpty(sb, "rst_sent", rstSent); + appendIfNotEmpty(sb, "rst_rcvd", rstRcvd); + appendIfNotEmpty(sb, "qso_date", qsoDate); + appendIfNotEmpty(sb, "time_on", timeOn); + appendIfNotEmpty(sb, "qso_date_off", qsoDateOff); + appendIfNotEmpty(sb, "time_off", timeOff); + appendIfNotEmpty(sb, "band", band); + appendIfNotEmpty(sb, "freq", freq); + appendIfNotEmpty(sb, "station_callsign", stationCallsign); + appendIfNotEmpty(sb, "my_gridsquare", myGridsquare); + appendIfNotEmpty(sb, "operator", operator); // POTA fields. Only emit when populated so non-POTA QSOs stay clean. appendIfNotEmpty(sb, "MY_SIG", mySig); appendIfNotEmpty(sb, "MY_SIG_INFO", mySigInfo); appendIfNotEmpty(sb, "SIG", sig); appendIfNotEmpty(sb, "SIG_INFO", sigInfo); - String c = comment == null ? "" : comment; - sb.append(String.format(Locale.US, "%s \n", utf8Length(c), c)); + appendIfNotEmpty(sb, "comment", comment); + sb.append("\n"); return sb.toString(); } @@ -152,13 +172,11 @@ public byte[] toBytes() { return build().getBytes(StandardCharsets.UTF_8); } - /** Emit a field when the value is non-null (an empty value still emits a zero-length field). */ - private static void appendIfNotNull(StringBuilder sb, String name, String value) { - if (value == null) return; - appendField(sb, name, value); - } - - /** Emit a field only when the value is non-null and non-empty (POTA fields). */ + /** + * Emit a field only when the value is non-null and non-empty. Omitting an empty + * optional field is the portable way to say "no value"; a zero-length field is not + * (see the class javadoc). + */ private static void appendIfNotEmpty(StringBuilder sb, String name, String value) { if (value == null || value.isEmpty()) return; appendField(sb, name, value); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/QSLRecord.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/QSLRecord.java index 1e41e945b..2dbad4992 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/log/QSLRecord.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/QSLRecord.java @@ -188,8 +188,17 @@ public QSLRecord(HashMap map) { if (map.containsKey("LOTW_QSL_RCVD")) {//QSO confirmation; present in Log32. isLotW_QSL = Objects.requireNonNull(map.get("LOTW_QSL_RCVD")).equalsIgnoreCase("Y"); } - if (map.containsKey("QSL_MANUAL")) {//Manual QSO confirmation; present in LoTW. - isQSL = Objects.requireNonNull(map.get("QSL_MANUAL")).equalsIgnoreCase("Y"); + // Manual QSO confirmation. We now export this as APP_FT8AF_QSL_MANUAL (ADIF has + // no QSL_MANUAL field), but files written by older FT8AF builds — and by other + // loggers that copied the bare name — must keep importing, so accept both. The + // conformant name wins when a file somehow carries both. + if (map.containsKey(AdifRecord.LEGACY_QSL_MANUAL)) { + isQSL = Objects.requireNonNull( + map.get(AdifRecord.LEGACY_QSL_MANUAL)).equalsIgnoreCase("Y"); + } + if (map.containsKey(AdifRecord.APP_QSL_MANUAL)) { + isQSL = Objects.requireNonNull( + map.get(AdifRecord.APP_QSL_MANUAL)).equalsIgnoreCase("Y"); } if (map.containsKey("MY_GRIDSQUARE")) {//My grid (present in LoTW/Log32, may be absent depending on LoTW settings); N1MM has no grid diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/ThirdPartyService.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/ThirdPartyService.java index bb9e65a8d..0404b4758 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/log/ThirdPartyService.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/ThirdPartyService.java @@ -328,8 +328,20 @@ public static boolean UploadToCloudLog(QSLRecord qslRecord){ * Returns true on HTTP 2xx, false otherwise. */ public static boolean uploadAdifToCloudlog(String adif) { + return uploadAdifToCloudlog(adif, null); + } + + /** + * As {@link #uploadAdifToCloudlog(String)}, but records why the upload failed into + * {@code failureOut} when one is supplied, so callers can surface the server's own + * explanation instead of a bare "0 uploaded". + */ + public static boolean uploadAdifToCloudlog(String adif, StringBuilder failureOut) { String address = GeneralVariables.getCloudlogServerAddress(); - if (address == null || address.isEmpty()) return false; + if (address == null || address.isEmpty()) { + appendFailure(failureOut, "no server address configured"); + return false; + } if (!address.endsWith("/")){ address+="/"; } @@ -340,11 +352,13 @@ public static boolean uploadAdifToCloudlog(String adif) { // Cloudlog's documented endpoint is /api/qso (no trailing slash). Wavelog and // Nextlog both 308-redirect when the trailing slash is present, which // HttpURLConnection won't follow on a POST. - String clRes = sendPostRequest(address+"api/qso",result); + String clRes = sendPostRequest(address+"api/qso", result, failureOut); Log.d(TAG, "Cloudlog upload " + (clRes != null ? "succeeded" : "failed")); return clRes != null; }catch (Exception k){ Log.d(TAG, "Cloudlog upload error: " + k.getClass().getSimpleName()); + appendFailure(failureOut, k.getClass().getSimpleName() + + (k.getMessage() != null ? ": " + k.getMessage() : "")); return false; } } @@ -409,8 +423,20 @@ public static boolean UploadToQRZ(QSLRecord qslRecord){ * still a success from a "the record is now on QRZ" standpoint). */ public static boolean uploadAdifToQrz(String adif) { + return uploadAdifToQrz(adif, null); + } + + /** + * As {@link #uploadAdifToQrz(String)}, but records the rejection reason into + * {@code failureOut} when one is supplied. QRZ answers with {@code RESULT=FAIL} + * plus a {@code REASON=...} pair, which is worth showing verbatim. + */ + public static boolean uploadAdifToQrz(String adif, StringBuilder failureOut) { String apikey = GeneralVariables.getQrzApiKey(); - if (apikey == null || apikey.isEmpty()) return false; + if (apikey == null || apikey.isEmpty()) { + appendFailure(failureOut, "no API key configured"); + return false; + } try { // POST keeps both the API key and the ADIF payload out of the URL. String body = "KEY=" + URLEncoder.encode(apikey, StandardCharsets.UTF_8.name()) @@ -418,16 +444,54 @@ public static boolean uploadAdifToQrz(String adif) { + "&ADIF=" + URLEncoder.encode(adif, StandardCharsets.UTF_8.name()); String result = sendPostFormRequest("https://logbook.qrz.com/api", body); Log.d(TAG, "QRZ upload " + (result != null ? "succeeded" : "failed")); - if (result == null) return false; + if (result == null) { + appendFailure(failureOut, "no response from QRZ"); + return false; + } // QRZ encodes status as RESULT=OK|FAIL|REPLACE within an &-separated body String qrzResult = parseQrzResult(result); - return "OK".equals(qrzResult) || "REPLACE".equals(qrzResult); + if ("OK".equals(qrzResult) || "REPLACE".equals(qrzResult)) { + return true; + } + appendFailure(failureOut, describeQrzFailure(result)); + return false; }catch (Exception k){ Log.d(TAG, "QRZ upload error: " + k.getClass().getSimpleName()); + appendFailure(failureOut, k.getClass().getSimpleName() + + (k.getMessage() != null ? ": " + k.getMessage() : "")); return false; } } + /** + * Summarise a rejected QRZ upload as {@code RESULT=} plus QRZ's {@code REASON} + * when it supplied one. Pure (no network) so it is unit-testable. + * + *

    Deliberately does not echo the whole response: it contains the submitted ADIF, + * and this string is written to {@code debug.log}. + */ + static String describeQrzFailure(String response) { + String result = parseQrzResult(response); + StringBuilder sb = new StringBuilder("RESULT=") + .append(result == null ? "(none)" : result); + if (response != null) { + for (String s : response.split("&")) { + String[] kv = s.split("=", 2); + if (kv.length > 1 && "REASON".equalsIgnoreCase(kv[0].trim())) { + String reason = kv[1].replaceAll("\\s+", " ").trim(); + if (!reason.isEmpty()) { + if (reason.length() > MAX_FAILURE_LEN) { + reason = reason.substring(0, MAX_FAILURE_LEN) + "…"; + } + sb.append(": ").append(reason); + } + break; + } + } + } + return sb.toString(); + } + /** * Extracts the {@code RESULT} value from a QRZ logbook API response. QRZ * replies with an {@code &}-separated body of {@code KEY=VALUE} pairs (e.g. @@ -458,14 +522,30 @@ public static class SyncResult { public final int qrzOk; public final boolean cloudlogAttempted; public final boolean qrzAttempted; - - SyncResult(int total, int cloudlogOk, int qrzOk, - boolean cloudlogAttempted, boolean qrzAttempted) { + /** + * Why the first failed Cloudlog upload of this run failed, or null if none did. + * Only the first is kept: when a server is broken every row fails the same way, + * and repeating that 113 times helps nobody. + */ + public final String cloudlogError; + /** Same, for QRZ. */ + public final String qrzError; + + public SyncResult(int total, int cloudlogOk, int qrzOk, + boolean cloudlogAttempted, boolean qrzAttempted) { + this(total, cloudlogOk, qrzOk, cloudlogAttempted, qrzAttempted, null, null); + } + + public SyncResult(int total, int cloudlogOk, int qrzOk, + boolean cloudlogAttempted, boolean qrzAttempted, + String cloudlogError, String qrzError) { this.total = total; this.cloudlogOk = cloudlogOk; this.qrzOk = qrzOk; this.cloudlogAttempted = cloudlogAttempted; this.qrzAttempted = qrzAttempted; + this.cloudlogError = cloudlogError; + this.qrzError = qrzError; } } @@ -523,6 +603,8 @@ public static SyncResult syncAllQSOs(SQLiteDatabase db, SyncProgress progress) { int total = 0; int cloudlogOk = 0; int qrzOk = 0; + String cloudlogError = null; + String qrzError = null; if (db == null || (!cl && !qrz)) { return new SyncResult(0, 0, 0, cl, qrz); } @@ -545,16 +627,22 @@ public static SyncResult syncAllQSOs(SQLiteDatabase db, SyncProgress progress) { boolean alreadyQrz = syncedQrzCol >= 0 && cursor.getInt(syncedQrzCol) == 1; if (cl && !alreadyCl) { String adif = buildAdifFromCursor(cursor, ServiceType.Cloudlog); - if (uploadAdifToCloudlog(adif)) { + StringBuilder why = cloudlogError == null ? new StringBuilder() : null; + if (uploadAdifToCloudlog(adif, why)) { cloudlogOk++; if (rowId >= 0) markRowSynced(db, rowId, "synced_cloudlog"); + } else if (why != null && why.length() > 0) { + cloudlogError = why.toString(); } } if (qrz && !alreadyQrz) { String adif = buildAdifFromCursor(cursor, ServiceType.QRZ); - if (uploadAdifToQrz(adif)) { + StringBuilder why = qrzError == null ? new StringBuilder() : null; + if (uploadAdifToQrz(adif, why)) { qrzOk++; if (rowId >= 0) markRowSynced(db, rowId, "synced_qrz"); + } else if (why != null && why.length() > 0) { + qrzError = why.toString(); } } done++; @@ -565,7 +653,7 @@ public static SyncResult syncAllQSOs(SQLiteDatabase db, SyncProgress progress) { } finally { if (cursor != null) cursor.close(); } - return new SyncResult(total, cloudlogOk, qrzOk, cl, qrz); + return new SyncResult(total, cloudlogOk, qrzOk, cl, qrz, cloudlogError, qrzError); } /** @@ -664,6 +752,21 @@ static String redactUrlApiKey(String url) { } public static String sendPostRequest(String url, String json) throws IOException { + return sendPostRequest(url, json, null); + } + + /** + * As {@link #sendPostRequest(String, String)}, but when the POST fails and + * {@code failureOut} is non-null, the reason is appended to it — HTTP status plus + * whatever the server said in the error body. + * + *

    Why this exists: the failure reason used to go only to {@code Log.d}, so a server + * that rejected every upload looked identical in {@code debug.log} to "nothing to do". + * A Cloudlog-compatible backend reports real, actionable problems this way (a missing + * DB column, a bad station id, an expired key), and none of it was reaching the user. + */ + static String sendPostRequest(String url, String json, StringBuilder failureOut) + throws IOException { // HttpURLConnection does not auto-follow 30x on a POST. Walk redirects manually // (capped) so deployments that rewrite trailing slashes, http→https, or move // the API path still work. @@ -703,6 +806,8 @@ public static String sendPostRequest(String url, String json) throws IOException if (loc == null || loc.isEmpty()) { Log.d(TAG, "POST " + redactUrlApiKey(currentUrl) + " -> HTTP " + responseCode + " (no Location header)"); + appendFailure(failureOut, + "HTTP " + responseCode + " redirect with no Location header"); return null; } // Resolve relative redirect against the previous URL. @@ -730,6 +835,7 @@ public static String sendPostRequest(String url, String json) throws IOException } catch (Exception ignored) {} Log.d(TAG, "POST " + redactUrlApiKey(currentUrl) + " -> HTTP " + responseCode + (err.length() > 0 ? " body=" + err : "")); + appendFailure(failureOut, describeHttpFailure(responseCode, err.toString())); return null; } finally { if (conn != null) { @@ -741,8 +847,41 @@ public static String sendPostRequest(String url, String json) throws IOException } } Log.d(TAG, "POST " + redactUrlApiKey(url) + " exceeded redirect limit"); + appendFailure(failureOut, "exceeded redirect limit"); return null; } + + private static void appendFailure(StringBuilder failureOut, String reason) { + if (failureOut == null || reason == null || reason.isEmpty()) return; + if (failureOut.length() > 0) failureOut.append("; "); + failureOut.append(reason); + } + + /** Longest failure description we keep. Server error bodies can be whole HTML pages. */ + private static final int MAX_FAILURE_LEN = 200; + + /** + * Render an HTTP failure as one short line: the status, plus the server's error body + * when it carried one. Pure (no network, no Android) so it is unit-testable. + * + *

    The body is collapsed to a single line and truncated — Cloudlog-compatible + * backends answer with a compact JSON envelope whose {@code messages} array holds the + * real cause, but a misconfigured proxy can just as easily return an HTML error page, + * and neither {@code debug.log} nor a toast wants all of that. + */ + static String describeHttpFailure(int responseCode, String body) { + StringBuilder sb = new StringBuilder("HTTP ").append(responseCode); + if (body != null) { + String flat = body.replaceAll("\\s+", " ").trim(); + if (!flat.isEmpty()) { + if (flat.length() > MAX_FAILURE_LEN) { + flat = flat.substring(0, MAX_FAILURE_LEN) + "…"; + } + sb.append(": ").append(flat); + } + } + return sb.toString(); + } public static String sendPostFormRequest(String url, String formBody) throws IOException { HttpURLConnection conn = null; BufferedReader reader = null; diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/sync/QsoAutoSync.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/sync/QsoAutoSync.kt index 6c419aca4..83fff358b 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/sync/QsoAutoSync.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/sync/QsoAutoSync.kt @@ -56,6 +56,24 @@ internal class QsoSyncGate(private val minIntervalMs: Long = 15_000L) { } } +/** + * Render a finished sync run as the one line that goes into `debug.log`. + * + * Always reports the counts; appends each service's failure reason when it had one. The + * counts alone are ambiguous in the worst case — `cloudlog=0 of 113` reads the same + * whether the network was down, the API key expired, or the server was rejecting every + * record — and that ambiguity once hid a broken logbook server for three days. Pure + * (no Android types) so it is unit-testable. + */ +internal fun summarize(result: ThirdPartyService.SyncResult): String { + val sb = StringBuilder( + "cloudlog=${result.cloudlogOk} qrz=${result.qrzOk} of ${result.total}" + ) + result.cloudlogError?.takeIf { it.isNotEmpty() }?.let { sb.append(" cloudlogError=").append(it) } + result.qrzError?.takeIf { it.isNotEmpty() }?.let { sb.append(" qrzError=").append(it) } + return sb.toString() +} + /** * Auto-uploads QSOs that failed to upload while offline. When a QSO is logged with no * internet, the immediate post-QSO upload fails and the row's `synced_*` flags stay 0; @@ -149,7 +167,7 @@ class QsoAutoSync(private val appContext: Context) { } log("start ($reason): $pending pending") val result = ThirdPartyService.syncAllQSOs(db, null) - log("done ($reason): cloudlog=${result.cloudlogOk} qrz=${result.qrzOk} of ${result.total}") + log("done ($reason): " + summarize(result)) } catch (e: Exception) { log("error ($reason): ${e.javaClass.simpleName} ${e.message}") } finally { diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprAdifLengthTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprAdifLengthTest.java index 485ad47f0..0f377cb99 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprAdifLengthTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprAdifLengthTest.java @@ -23,9 +23,11 @@ * requires), not the UTF-16 {@link String#length()} char count. The two differ for any non-ASCII * content (an accented comment, an international POTA park name): declaring the shorter char count * makes a byte-correct consumer (LoTW/QRZ/Cloudlog) read fewer bytes than were written, truncating - * the field and mis-aligning the record boundary. The canonical writer {@link - * com.k1af.ft8af.log.AdifRecord} already uses {@code AdifFormat.utf8Length}; this covers the - * un-migrated {@code downQSLTable} twin used by the built-in web logbook download. + * the field and mis-aligning the record boundary. + * + *

    {@code downQSLTable} — the built-in web logbook's download — now builds its records through + * the canonical {@link com.k1af.ft8af.log.AdifRecord} rather than its own inline copy, so these + * cases are covered twice over. They stay here as the guard on the cursor-to-record mapping. */ @RunWith(RobolectricTestRunner.class) public class DatabaseOprAdifLengthTest { @@ -103,10 +105,39 @@ public void asciiComment_unchanged_declaresCharLength() { } @Test - public void nullComment_doesNotThrow_emitsEmptyField() { + public void nullComment_doesNotThrow_andIsOmittedEntirely() { // Previously comment.length() NPE'd on a NULL comment, aborting the whole download. + // It then emitted " " instead; a zero-length field is not portable, so an + // absent comment is now simply absent. String adif = export(null, null, null); - assertThat(adif).contains(" "); + assertThat(adif).doesNotContain("comment"); + assertThat(adif).endsWith("\n"); + } + + @Test + public void emptyOptionalColumns_produceNoZeroLengthFields() { + // Regression: a QSO logged without a grid (the other station never sent one) used to + // export " ", which strict ADIF importers reject. + MatrixCursor cursor = new MatrixCursor(COLUMNS); + cursor.addRow(new Object[]{ + "W1AW", 0, 0, "", "FT8", "", "", + "20260101", "1200", "", "", "20m", "14074000", + "", "", "", + "", "", "", "", "" + }); + String adif = opr.downQSLTable(cursor, false); + + assertThat(adif).doesNotContain(":0>"); + assertThat(adif).contains("W1AW "); + assertThat(adif).endsWith("\n"); + } + + @Test + public void webLogbookExport_usesTheConformantManualQslFieldName() { + // The web logbook download and the file export must agree; both go through AdifRecord. + String adif = export("QSO by FT8AF", null, null); + assertThat(adif).contains("N "); + assertThat(adif).doesNotContain("([^<]*?) (?=<|$)"); + Pattern.compile("<([A-Za-z0-9_]+):(\\d+)>([^<]*?) (?=<|$)"); @Test public void oneQso_producesExactlyOneEorRecord() { @@ -45,16 +45,26 @@ public void oneQso_producesExactlyOneEorRecord() { public void nonSwl_emitsQslRcvdAndManualPair() { String out = new AdifRecord().call("W1AW").lotwQsl(true).manualQsl(false).build(); assertThat(out).contains("Y "); - assertThat(out).contains("N "); + assertThat(out).contains("N "); assertThat(out).doesNotContain("_ is the spec's mechanism + // for program-specific data and is safely skippable by consumers that don't know it. + String out = new AdifRecord().call("W1AW").manualQsl(true).build(); + assertThat(out).contains("Y "); + assertThat(out).doesNotContain("Y "); assertThat(out).doesNotContain(" omitted entirely. + public void nullAndEmptyFieldsAreBothOmitted() { + // Regression: an empty grid used to emit " ". Several ADIF importers + // treat a length-0 field as malformed and reject the whole record, so an absent + // optional field is the only portable way to say "no value". assertThat(new AdifRecord().call("W1AW").gridsquare(null).build()) .doesNotContain("gridsquare"); - // gridsquare == "" -> still emitted as a zero-length field (historical behaviour). assertThat(new AdifRecord().call("W1AW").gridsquare("").build()) - .contains(" "); + .doesNotContain("gridsquare"); + } + + @Test + public void noFieldIsEverEmittedWithZeroLength() { + // A record where every optional field is empty must still be a valid record — + // just a sparse one — with no "" anywhere in it. + String out = new AdifRecord() + .call("W1AW") + .gridsquare("").mode("").rstSent("").rstRcvd("") + .qsoDate("").timeOn("").qsoDateOff("").timeOff("") + .band("").freq("").stationCallsign("").myGridsquare("") + .operator("").mySig("").mySigInfo("").sig("").sigInfo("") + .comment("") + .build(); + + assertThat(out).doesNotContain(":0>"); + assertThat(out).contains("W1AW "); + assertThat(out).endsWith("\n"); } @Test - public void comment_isAlwaysEmittedEvenWhenNull() { + public void comment_isOmittedWhenNullOrEmptyButRecordStillTerminates() { + // The used to be glued onto the comment field, so a commentless QSO got a + // zero-length comment purely to carry the terminator. They are separate now. assertThat(new AdifRecord().call("W1AW").comment(null).build()) - .contains(" \n"); + .isEqualTo("W1AW N N \n"); + assertThat(new AdifRecord().call("W1AW").comment("").build()) + .doesNotContain("comment"); + assertThat(new AdifRecord().call("W1AW").comment("hi").build()) + .contains("hi \n"); } @Test diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/log/QSLRecordTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/log/QSLRecordTest.java index 050d9c2bb..d75ef9629 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/log/QSLRecordTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/log/QSLRecordTest.java @@ -111,6 +111,53 @@ public void mapConstructor_mfskSubmodeResolvesToFt4() { assertThat(r.getMode()).isEqualTo("FT4"); } + @Test + public void mapConstructor_readsManualQslUnderTheAppPrefixedName() { + HashMap map = new HashMap<>(); + map.put("CALL", "W1AW"); + map.put("COMMENT", "imported"); + map.put(AdifRecord.APP_QSL_MANUAL, "Y"); + + assertThat(new QSLRecord(map).isQSL).isTrue(); + } + + @Test + public void mapConstructor_stillReadsTheLegacyBareQslManualName() { + // ADIF files written by FT8AF before the APP_ prefix — and by other loggers that + // copied the bare name — must keep importing with their confirmation flag intact. + HashMap map = new HashMap<>(); + map.put("CALL", "W1AW"); + map.put("COMMENT", "imported"); + map.put(AdifRecord.LEGACY_QSL_MANUAL, "Y"); + + assertThat(new QSLRecord(map).isQSL).isTrue(); + } + + @Test + public void mapConstructor_manualQslDefaultsToFalseAndHonoursN() { + HashMap plain = new HashMap<>(); + plain.put("CALL", "W1AW"); + plain.put("COMMENT", "imported"); + assertThat(new QSLRecord(plain).isQSL).isFalse(); + + HashMap explicitN = new HashMap<>(); + explicitN.put("CALL", "W1AW"); + explicitN.put("COMMENT", "imported"); + explicitN.put(AdifRecord.APP_QSL_MANUAL, "N"); + assertThat(new QSLRecord(explicitN).isQSL).isFalse(); + } + + @Test + public void mapConstructor_conformantNameWinsWhenBothArePresent() { + HashMap map = new HashMap<>(); + map.put("CALL", "W1AW"); + map.put("COMMENT", "imported"); + map.put(AdifRecord.LEGACY_QSL_MANUAL, "Y"); + map.put(AdifRecord.APP_QSL_MANUAL, "N"); + + assertThat(new QSLRecord(map).isQSL).isFalse(); + } + @Test public void mapConstructor_mfskSubmodeResolvesToFt2() { HashMap map = new HashMap<>(); diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/log/ThirdPartyServiceFailureReasonTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/log/ThirdPartyServiceFailureReasonTest.java new file mode 100644 index 000000000..cdcb0c2db --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/log/ThirdPartyServiceFailureReasonTest.java @@ -0,0 +1,100 @@ +package com.k1af.ft8af.log; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Unit tests for the upload-failure descriptions in {@link ThirdPartyService}. + * + *

    Motivation: a Cloudlog-compatible server that rejects every QSO (say, its + * {@code contacts} table is missing a column its INSERT references) used to be + * indistinguishable in {@code debug.log} from having nothing to upload — both printed + * {@code cloudlog=0}. These helpers turn the server's own explanation into one short line. + * Pure string logic, no network. + */ +public class ThirdPartyServiceFailureReasonTest { + + @Test + public void httpFailure_includesStatusAndServerBody() { + String body = "{\"status\":\"abort\",\"adif_errors\":1," + + "\"messages\":[\"column \\\"tx_pwr\\\" of relation \\\"contacts\\\" does not exist\"]}"; + String out = ThirdPartyService.describeHttpFailure(400, body); + + assertThat(out).startsWith("HTTP 400: "); + assertThat(out).contains("tx_pwr"); + assertThat(out).contains("does not exist"); + } + + @Test + public void httpFailure_withNoBody_isJustTheStatus() { + assertThat(ThirdPartyService.describeHttpFailure(502, null)).isEqualTo("HTTP 502"); + assertThat(ThirdPartyService.describeHttpFailure(502, "")).isEqualTo("HTTP 502"); + assertThat(ThirdPartyService.describeHttpFailure(502, " \n ")).isEqualTo("HTTP 502"); + } + + @Test + public void httpFailure_collapsesNewlinesSoTheLogStaysOneLinePerEntry() { + String out = ThirdPartyService.describeHttpFailure(500, "line one\nline two\r\n\tline three"); + + assertThat(out).doesNotContain("\n"); + assertThat(out).doesNotContain("\r"); + assertThat(out).isEqualTo("HTTP 500: line one line two line three"); + } + + @Test + public void httpFailure_truncatesAnEnormousBody() { + StringBuilder huge = new StringBuilder(); + for (int i = 0; i < 500; i++) { + huge.append("error page"); + } + String out = ThirdPartyService.describeHttpFailure(500, huge.toString()); + + // "HTTP 500: " + 200 chars + the ellipsis. + assertThat(out.length()).isLessThan(240); + assertThat(out).endsWith("…"); + } + + @Test + public void qrzFailure_reportsResultAndReason() { + String out = ThirdPartyService.describeQrzFailure( + "RESULT=FAIL&REASON=Unable to add QSO to database: duplicate&EXTENDED="); + + assertThat(out).isEqualTo("RESULT=FAIL: Unable to add QSO to database: duplicate"); + } + + @Test + public void qrzFailure_withoutReason_reportsResultAlone() { + assertThat(ThirdPartyService.describeQrzFailure("RESULT=FAIL&COUNT=0")) + .isEqualTo("RESULT=FAIL"); + } + + @Test + public void qrzFailure_withUnparseableResponse_saysSo() { + assertThat(ThirdPartyService.describeQrzFailure("")).isEqualTo("RESULT=(none)"); + assertThat(ThirdPartyService.describeQrzFailure(null)).isEqualTo("RESULT=(none)"); + assertThat(ThirdPartyService.describeQrzFailure("gateway timeout")) + .isEqualTo("RESULT=(none)"); + } + + @Test + public void qrzFailure_neverEchoesTheSubmittedAdif() { + // The response echoes back what we sent; debug.log must not accumulate whole logbooks. + String response = "RESULT=FAIL&REASON=bad record&ADIF=W1AW secret ok "; + String out = ThirdPartyService.describeQrzFailure(response); + + assertThat(out).doesNotContain("W1AW"); + assertThat(out).doesNotContain("secret"); + } + + @Test + public void syncResult_defaultsToNoRecordedErrors() { + ThirdPartyService.SyncResult r = + new ThirdPartyService.SyncResult(5, 5, 0, true, false); + + assertThat(r.cloudlogError).isNull(); + assertThat(r.qrzError).isNull(); + assertThat(r.total).isEqualTo(5); + assertThat(r.cloudlogOk).isEqualTo(5); + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/sync/QsoAutoSyncSummaryTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/sync/QsoAutoSyncSummaryTest.kt new file mode 100644 index 000000000..49d590c15 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/sync/QsoAutoSyncSummaryTest.kt @@ -0,0 +1,78 @@ +package radio.ks3ckc.ft8af.sync + +import com.google.common.truth.Truth.assertThat +import com.k1af.ft8af.log.ThirdPartyService +import org.junit.Test + +/** + * Unit tests for [summarize], the `debug.log` line for a finished auto-sync run. + * + * The counts alone were ambiguous: `cloudlog=0 qrz=0 of 113` reads identically whether the + * network was down, the key expired, or the server rejected every record. Attaching the + * server's own reason is what turns that line into a diagnosis. + */ +class QsoAutoSyncSummaryTest { + private fun result( + total: Int, + cloudlogOk: Int, + qrzOk: Int, + cloudlogError: String? = null, + qrzError: String? = null, + ): ThirdPartyService.SyncResult = + ThirdPartyService.SyncResult( + total, + cloudlogOk, + qrzOk, + true, + true, + cloudlogError, + qrzError, + ) + + @Test + fun `fully successful run reports counts only`() { + val summary = summarize(result(12, 12, 12)) + + assertThat(summary).isEqualTo("cloudlog=12 qrz=12 of 12") + } + + @Test + fun `failing cloudlog run carries the server explanation`() { + val reason = "HTTP 400: column \"tx_pwr\" of relation \"contacts\" does not exist" + val summary = summarize(result(113, 0, 113, cloudlogError = reason)) + + assertThat(summary).startsWith("cloudlog=0 qrz=113 of 113") + assertThat(summary).contains("cloudlogError=HTTP 400:") + assertThat(summary).contains("tx_pwr") + assertThat(summary).doesNotContain("qrzError=") + } + + @Test + fun `both services failing report both reasons`() { + val summary = + summarize( + result(4, 0, 0, cloudlogError = "HTTP 401", qrzError = "RESULT=FAIL: bad key"), + ) + + assertThat(summary) + .isEqualTo("cloudlog=0 qrz=0 of 4 cloudlogError=HTTP 401 qrzError=RESULT=FAIL: bad key") + } + + @Test + fun `empty error strings are treated as absent`() { + // An empty StringBuilder must not produce a dangling "cloudlogError=". + val summary = summarize(result(1, 1, 1, cloudlogError = "", qrzError = "")) + + assertThat(summary).isEqualTo("cloudlog=1 qrz=1 of 1") + } + + @Test + fun `summary is a single line so it cannot corrupt the log`() { + val summary = + summarize( + result(2, 0, 0, cloudlogError = "HTTP 500: a b c", qrzError = "RESULT=FAIL"), + ) + + assertThat(summary).doesNotContain("\n") + } +} From 3eb767f275bc78bfed248bd8abb7057192c10c4d Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 28 Jul 2026 09:25:23 -0500 Subject: [PATCH 092/113] Make the early and full-slot decoders work in tandem instead of competing (#702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: schedule the late full-slot decode so it works in tandem with the early pass With deep decode + early decode both on, the subtract-and-redecode loop ran on the truncated early buffer with an 11.25s budget (after an unbounded first deep pass), so the late full-slot pass didn't start until right as the next slot's early decode began — its first analysis-gate contention then aborted the whole late candidate scan, dropping the high-DT signals the pass exists to recover, almost every cycle. Now the two buffers split the work instead of duplicating it: - The early buffer keeps the time-critical fast pass and the quick first deep pass (pre-key-up sequencer evidence) — unchanged. - The subtraction loop moves to the full-slot buffer (a strict superset of the early one), which becomes the slot's single deep engine and also recovers high-DT signals ~12s earlier than before. - The whole full-slot pass is bounded by an absolute deadline (next slot boundary + early window − 750ms safety, capped by the deep budget), so it finishes before the next slot's early decode starts; the analysis-gate abort becomes a backstop for overruns instead of the routine exit path. No behavior change when no late pass is scheduled (early decode off, or FT4/FT2): the early-buffer subtraction loop runs exactly as before. Co-Authored-By: Claude Fable 5 * docs: clarify latePassDeadlineMillis assumes record-time cycle timing Per Copilot review on #702: the deadline is computed from the slot's record-time ModeProfile snapshot, so it only equals the next slot's actual early-decode start while the cycle timing is unchanged. Document that a mid-slot rebuildTimer() (mode switch) moves the real boundary and that the AnalysisGate contention abort is the backstop in that case. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../ft8af/ft8listener/FT8SignalListener.java | 104 ++++++++++++------ .../k1af/ft8af/ft8listener/LateDecode.java | 80 +++++++++++++- .../ft8af/ft8listener/LateDecodeTest.java | 71 +++++++++++- 3 files changed, 214 insertions(+), 41 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FT8SignalListener.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FT8SignalListener.java index e4efd00c2..afdb5acb0 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FT8SignalListener.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FT8SignalListener.java @@ -251,29 +251,38 @@ public void run() { onFt8Listen.afterDecode(utc, averageOffset(allMsg), UtcTimer.sequential(utc), msgs, true); } - final long deepDecodeBudgetMs = mode.deepDecodeBudgetMillis(); - // Budget the subtract-and-redecode loop by ITS OWN elapsed time, not the total - // decode time. On a slow device the initial fast + first deep pass can already - // exceed the budget, which would abort subtraction before it ran even once. - final long deepLoopStart = System.currentTimeMillis(); - // Passes inside the loop also stop their candidate scan at this instant, - // so one pass over a huge candidate list can't blow through the budget. - final long passDeadline = DeepDecodeBudget.passDeadline(deepLoopStart, deepDecodeBudgetMs); - do { - if (DeepDecodeBudget.loopExhausted(deepLoopStart, System.currentTimeMillis(), deepDecodeBudgetMs)) break;// stop once the subtraction loop has spent its budget - // subtract decoded signals - subtractDecode(ft8Decoder, a91List, fromSource); - - // perform another decode pass - msgs = runDecode(ft8Decoder, utc, true, mode, passDeadline, a91List, null); - addMsgToList(allMsg, msgs); - timeSec = System.currentTimeMillis() - time; - decodeTimeSec.postValue(timeSec);// decode elapsed time - if (onFt8Listen != null) { - onFt8Listen.afterDecode(utc, averageOffset(allMsg), UtcTimer.sequential(utc), msgs, true); - } + // The subtract-and-redecode loop runs on the early buffer ONLY when no + // late full-slot pass is scheduled (early decode off, or FT4/FT2). + // With a late pass pending, the loop is deferred to the full-slot + // buffer (a strict superset of this one): running it here doubled the + // deep work and pushed the late pass's start past the next slot's + // early decode, whose first gate contention then aborted the late + // candidate scan almost every cycle. See LateDecode.deepLoopRunsOnEarlyBuffer. + if (LateDecode.deepLoopRunsOnEarlyBuffer(lateHandoff)) { + final long deepDecodeBudgetMs = mode.deepDecodeBudgetMillis(); + // Budget the subtract-and-redecode loop by ITS OWN elapsed time, not the total + // decode time. On a slow device the initial fast + first deep pass can already + // exceed the budget, which would abort subtraction before it ran even once. + final long deepLoopStart = System.currentTimeMillis(); + // Passes inside the loop also stop their candidate scan at this instant, + // so one pass over a huge candidate list can't blow through the budget. + final long passDeadline = DeepDecodeBudget.passDeadline(deepLoopStart, deepDecodeBudgetMs); + do { + if (DeepDecodeBudget.loopExhausted(deepLoopStart, System.currentTimeMillis(), deepDecodeBudgetMs)) break;// stop once the subtraction loop has spent its budget + // subtract decoded signals + subtractDecode(ft8Decoder, a91List, fromSource); + + // perform another decode pass + msgs = runDecode(ft8Decoder, utc, true, mode, passDeadline, a91List, null); + addMsgToList(allMsg, msgs); + timeSec = System.currentTimeMillis() - time; + decodeTimeSec.postValue(timeSec);// decode elapsed time + if (onFt8Listen != null) { + onFt8Listen.afterDecode(utc, averageOffset(allMsg), UtcTimer.sequential(utc), msgs, true); + } - } while (msgs.size() > 0 ); + } while (msgs.size() > 0 ); + } } // Moved to finalize() method @@ -295,12 +304,20 @@ public void run() { } /** - * Decode the full-slot buffer captured alongside the early window, recovering signals - * whose DT pushed them past the early window's end. Deliveries reuse the slot's - * {@code allMsg} dedup scope, so everything the early passes already reported is - * filtered out and only genuinely new messages reach {@code afterDecode} — flagged - * isDeep=true so the auto-sequencer (which already acted on the early results) is not - * triggered a second time. + * Decode the full-slot buffer captured alongside the early window: the slot's DEPTH + * pass, complementing the early buffer's SPEED pass. It recovers signals whose DT + * pushed them past the early window's end, and — with deep decode on — hosts the + * slot's subtract-and-redecode loop (deferred here from the early buffer, which is a + * strict prefix of this one; see {@link LateDecode#deepLoopRunsOnEarlyBuffer}). + * Deliveries reuse the slot's {@code allMsg} dedup scope, so everything the early + * passes already reported is filtered out and only genuinely new messages reach + * {@code afterDecode} — flagged isDeep=true so the auto-sequencer treats them as + * evidence rather than re-triggering on them. + * + *

    Every candidate loop in here is bounded by the ABSOLUTE window deadline (next + * slot boundary + early window − safety margin, capped by the mode's deep budget): + * the pass must finish before the next slot's early decode thread starts, so the + * analysis-gate abort is a backstop for overruns, not the routine exit path. * *

    Deliberately does NOT touch {@link #timeSec}/{@link #decodeTimeSec}: by the time * this runs the next slot may already be decoding, and clobbering the shared elapsed @@ -323,19 +340,36 @@ private void runLateFullSlotPass(long utc, ArrayList allMsg, A91List long lateDecoder = initDecoder(utc, fullData.length, mode); try { pressFloatDecode(fullData, lateDecoder, fromSource); - // Bound the whole late pass (fast + deep candidate loops) by the mode's deep - // budget so a slow device can't chain late work across multiple later slots. - final long deadline = DeepDecodeBudget.passDeadline(time, - mode.deepDecodeBudgetMillis()); + final long deadline = LateDecode.effectiveLatePassDeadline( + handoff.latePassDeadlineEpochMs, + DeepDecodeBudget.passDeadline(time, mode.deepDecodeBudgetMillis())); // Best-effort priority: the first analysis-gate contention with the next // slot's early decode trips this and the late pass gives up the rest of its - // candidates (and the deep pass). See LateDecode.AnalysisGate. + // candidates (and the deep loop). See LateDecode.AnalysisGate. final LateDecode.LateAbort lateAbort = new LateDecode.LateAbort(); deliverLateMessages(utc, allMsg, runDecode(lateDecoder, utc, false, mode, deadline, a91List, lateAbort)); if (GeneralVariables.deepDecodeMode && !lateAbort.tripped()) { - deliverLateMessages(utc, allMsg, - runDecode(lateDecoder, utc, true, mode, deadline, a91List, lateAbort)); + ArrayList msgs = + runDecode(lateDecoder, utc, true, mode, deadline, a91List, lateAbort); + deliverLateMessages(utc, allMsg, msgs); + // Subtract-and-redecode on the full buffer. Same convergence rule as the + // early-buffer loop this replaces (do/while: at least one subtraction + // round even when the deep pass above surfaced nothing new — subtraction + // is what uncovers weak signals UNDER the already-decoded strong ones): + // keep going while a pass still yields NEW messages (deliverLateMessages + // strips already-delivered ones from msgs in place) and the deadline + // hasn't passed. a91List holds the previous pass's decodes — exactly + // what the next subtraction removes. + do { + if (lateAbort.tripped() + || DeepDecodeBudget.passExpired(deadline, System.currentTimeMillis())) { + break; + } + subtractDecode(lateDecoder, a91List, fromSource); + msgs = runDecode(lateDecoder, utc, true, mode, deadline, a91List, lateAbort); + deliverLateMessages(utc, allMsg, msgs); + } while (!msgs.isEmpty()); } } finally { deleteDecoder(lateDecoder, fromSource); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/LateDecode.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/LateDecode.java index d5414ab64..cf5ec813e 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/LateDecode.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/LateDecode.java @@ -17,6 +17,16 @@ * second buffer; the still-running decode thread picks that buffer up after the early * passes finish and decodes it as an extra, late delivery. * + *

    The two buffers are complementary, not competing: the early pass exists for SPEED + * (its fast results feed the auto-sequencer in time for the next slot's TX), the + * full-slot pass for DEPTH (high-DT recovery, and — with deep decode on — the + * subtract-and-redecode loop, which runs here instead of on the truncated early buffer; + * see {@link #deepLoopRunsOnEarlyBuffer}). The full-slot pass is bounded by + * {@link #latePassDeadlineMillis} so it normally finishes before the next slot's early + * decode starts rather than colliding with it, with the {@link AnalysisGate} contention + * abort as the backstop when it can't (per-candidate overrun, or a mid-slot timer + * rebuild moving the next boundary). + * *

    This class holds the parts of that feature that are decisions/state rather than * native-decoder plumbing, so they stay unit-testable: the should-run predicate, the * bounded-wait math, and the one-shot buffer handoff between the recorder's monitor @@ -34,8 +44,63 @@ public final class LateDecode { */ static final long DELIVERY_GRACE_MS = 3000; + /** + * Safety margin subtracted from the late pass's absolute deadline (next slot boundary + * + early window = the instant the next slot's early decode thread starts). The + * deadline is only checked between candidates, so the late pass can overrun it by one + * candidate's analysis — deep multi-iteration LDPC on a busy band runs into the + * hundreds of milliseconds. The margin absorbs that plus scheduling jitter, so the + * analysis-gate abort stays a backstop instead of the routine exit path. + */ + static final long LATE_PASS_SAFETY_MS = 750; + private LateDecode() {} + /** + * Absolute wall-clock instant the late full-slot pass must finish by: when the NEXT + * slot's early decode thread starts (next boundary + early window), minus + * {@link #LATE_PASS_SAFETY_MS}. Computed from the slot's RECORD-TIME mode snapshot, + * so it assumes the next slot keeps the same cycle timing; if the operator switches + * modes mid-slot, {@code FT8SignalListener.rebuildTimer(...)} moves the actual next + * boundary and this bound no longer tracks it — in that (rare) case the + * {@link AnalysisGate} contention abort is the backstop, exactly as it is for a + * per-candidate overrun. Before this bound existed the late pass was only + * budget-bounded; with deep decode on, the early buffer's subtraction loop pushed its + * start right up against the next early decode, so its first gate contention aborted + * the whole candidate scan almost every slot — the two decode passes fought instead + * of cooperating. + * + * @param registeredAtMs wall-clock time the full-slot monitor was registered + * (the slot boundary) + * @param mode the slot's RECORD-TIME mode snapshot + */ + static long latePassDeadlineMillis(long registeredAtMs, ModeProfile mode) { + return registeredAtMs + mode.slotMillis + mode.earlyDecodeMillis - LATE_PASS_SAFETY_MS; + } + + /** + * The deadline the late full-slot pass actually runs under: the window bound above + * capped by the mode's deep budget (a slow device must not chain late work across + * multiple later slots even when the window would allow it). + */ + public static long effectiveLatePassDeadline(long windowDeadlineEpochMs, + long budgetDeadlineEpochMs) { + return Math.min(windowDeadlineEpochMs, budgetDeadlineEpochMs); + } + + /** + * Where this slot's deep subtract-and-redecode loop runs. With no late pass scheduled + * (early decode off, or FT4/FT2) the early buffer is the only buffer, so the loop runs + * there. When a late full-slot pass IS scheduled, the early buffer is a strict prefix + * of the full-slot buffer — running the loop on both would duplicate the deep work AND + * delay the late pass past the next slot's early decode (the "decoders fighting" + * failure). So the loop is deferred to the full-slot pass, which becomes the slot's + * single deep engine. + */ + public static boolean deepLoopRunsOnEarlyBuffer(Handoff lateHandoff) { + return lateHandoff == null; + } + /** * Whether this cycle should schedule the second, full-slot decode pass. * @@ -68,7 +133,7 @@ public static boolean shouldRunLatePass(boolean earlyDecode, ModeProfile mode) { public static SlotPlan planSlot(boolean earlyDecode, ModeProfile mode, long nowMs) { int recordMillis = earlyDecode ? mode.earlyDecodeMillis : mode.slotMillis; Handoff lateHandoff = shouldRunLatePass(earlyDecode, mode) - ? new Handoff(nowMs, mode.slotMillis) + ? new Handoff(nowMs, mode) : null; return new SlotPlan(mode, recordMillis, lateHandoff); } @@ -120,13 +185,20 @@ static long remainingWaitMillis(long deadlineEpochMs, long nowMs) { public static final class Handoff { private final ArrayBlockingQueue queue = new ArrayBlockingQueue<>(1); private final long deadlineEpochMs; + /** + * Absolute instant the late full-slot pass must finish by — the next slot's + * early-decode start minus the safety margin. See {@link #latePassDeadlineMillis}. + */ + public final long latePassDeadlineEpochMs; /** * @param registeredAtMs wall-clock time the full-slot voice monitor was registered - * @param slotMillis the mode's slot length (== the monitor's duration) + * (the slot boundary) + * @param mode the slot's RECORD-TIME mode snapshot */ - public Handoff(long registeredAtMs, int slotMillis) { - this.deadlineEpochMs = deliveryDeadlineMillis(registeredAtMs, slotMillis); + public Handoff(long registeredAtMs, ModeProfile mode) { + this.deadlineEpochMs = deliveryDeadlineMillis(registeredAtMs, mode.slotMillis); + this.latePassDeadlineEpochMs = latePassDeadlineMillis(registeredAtMs, mode); } /** diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ft8listener/LateDecodeTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ft8listener/LateDecodeTest.java index e9cd862c3..9f3be2176 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/ft8listener/LateDecodeTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ft8listener/LateDecodeTest.java @@ -65,7 +65,7 @@ public void remainingWait_neverNegative_pastDeadline() { private static LateDecode.Handoff handoffWithLiveDeadline() { // Registered "now": deadline is ~18s away, so an offered buffer is returned // immediately and no test ever actually waits that long. - return new LateDecode.Handoff(System.currentTimeMillis(), 15_000); + return new LateDecode.Handoff(System.currentTimeMillis(), ModeProfile.FT8); } private static LateDecode.Handoff handoffWithExpiredDeadline() { @@ -73,7 +73,7 @@ private static LateDecode.Handoff handoffWithExpiredDeadline() { // awaitBuffer must return null without blocking. return new LateDecode.Handoff( System.currentTimeMillis() - 15_000 - LateDecode.DELIVERY_GRACE_MS - 1_000, - 15_000); + ModeProfile.FT8); } @Test @@ -163,6 +163,73 @@ public void planSlot_fastModes_earlyWindowButNoLatePass() { } } + // ---- late-pass scheduling: the full-slot pass must coexist with the NEXT slot's + // early decode instead of fighting it (the "decoders working against each other" bug: + // the early buffer's subtraction loop delayed the late pass until it collided with + // the next early decode and aborted its whole candidate scan). + + @Test + public void latePassDeadline_endsBeforeNextSlotsEarlyDecodeStarts() { + // Next slot's early decode thread starts at boundary + slot + earlyWindow; the + // late pass must be done a safety margin before that. + long registeredAt = 1_000_000L; + long nextEarlyDecodeStart = registeredAt + ModeProfile.FT8.slotMillis + + ModeProfile.FT8.earlyDecodeMillis; + assertThat(LateDecode.latePassDeadlineMillis(registeredAt, ModeProfile.FT8)) + .isEqualTo(nextEarlyDecodeStart - LateDecode.LATE_PASS_SAFETY_MS); + } + + @Test + public void latePassDeadline_leavesUsefulDecodeWindow() { + // Sanity on the constants: the window between buffer arrival (boundary + slot) + // and the deadline must stay big enough for real deep work — if a future tuning + // of earlyDecodeMillis/safety collapses it, the late pass silently degenerates + // back into an abort-every-slot pass. + long registeredAt = 0L; + long bufferArrival = ModeProfile.FT8.slotMillis; + long window = LateDecode.latePassDeadlineMillis(registeredAt, ModeProfile.FT8) + - bufferArrival; + assertThat(window).isAtLeast(10_000L); + } + + @Test + public void effectiveLatePassDeadline_isTheEarlierOfWindowAndBudget() { + assertThat(LateDecode.effectiveLatePassDeadline(1_000L, 2_000L)).isEqualTo(1_000L); + assertThat(LateDecode.effectiveLatePassDeadline(2_000L, 1_000L)).isEqualTo(1_000L); + assertThat(LateDecode.effectiveLatePassDeadline(1_500L, 1_500L)).isEqualTo(1_500L); + } + + @Test + public void handoff_carriesLatePassDeadline() { + long registeredAt = 1_000_000L; + LateDecode.Handoff handoff = new LateDecode.Handoff(registeredAt, ModeProfile.FT8); + assertThat(handoff.latePassDeadlineEpochMs) + .isEqualTo(LateDecode.latePassDeadlineMillis(registeredAt, ModeProfile.FT8)); + } + + // ---- deep-loop placement: exactly one buffer per slot hosts the subtraction loop -- + + @Test + public void deepLoop_runsOnEarlyBuffer_whenNoLatePassScheduled() { + // Early decode off (or FT4/FT2): the early buffer is the only buffer. + assertThat(LateDecode.deepLoopRunsOnEarlyBuffer(null)).isTrue(); + } + + @Test + public void deepLoop_deferredToFullSlotPass_whenLatePassScheduled() { + // With a full-slot pass pending, deep work on the (strict-prefix) early buffer + // would be duplicated AND delay the late pass into the next slot's early decode. + LateDecode.SlotPlan plan = LateDecode.planSlot(true, ModeProfile.FT8, 1_000_000L); + assertThat(LateDecode.deepLoopRunsOnEarlyBuffer(plan.lateHandoff)).isFalse(); + } + + @Test + public void planSlot_handoffLatePassDeadline_anchoredAtPlanTime() { + LateDecode.SlotPlan plan = LateDecode.planSlot(true, ModeProfile.FT8, 1_000_000L); + assertThat(plan.lateHandoff.latePassDeadlineEpochMs) + .isEqualTo(LateDecode.latePassDeadlineMillis(1_000_000L, ModeProfile.FT8)); + } + @Test public void planSlot_handoffDeadline_anchoredAtPlanTime() { LateDecode.SlotPlan plan = LateDecode.planSlot(true, ModeProfile.FT8, 1_000_000L); From 1867b8b041eeffbfab596f4f0b962eae58ed1027 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 28 Jul 2026 09:26:20 -0500 Subject: [PATCH 093/113] Add Hunt options: target priority + smart filters (pileup avoidance, min-SNR floor) (#699) * feat: add Hunt options sheet with target priority and smart filters The HUNT button gets the same notch/long-press affordance as the CQ button, opening a Hunt options sheet: - Hunt priority (single-select): Latest (historical behavior, default), Strongest, Weakest, Farthest, POTA/SOTA activators first (unhunted parks rank highest), New DXCC first, New grid first. - Smart filters: Avoid pileups (prefer CQs no one else is answering this cycle; soft preference) and a Minimum-signal floor (Off/-10/-15/-20 dB; hard filter so Hunt never starts a QSO that's unlikely to complete). Engine: the hunt scan in FT8TransmitSignal previously answered the first qualifying CQ (most recent decode). It now collects all qualifying CQs and ranks them via HuntTargetSelector, a pure Kotlin selector driven by three new persisted settings (huntPriority, huntAvoidPileups, huntMinSnr). LATEST with no filters short-circuits to the old behavior with no extra per-cycle work. All existing eligibility filters (worked-before, POTA-only, directional-CQ respect, exclusions) are unchanged. UI: a non-default priority shows a short tag under the HUNT label (STRONG/WEAK/DX/POTA/DXCC/GRID), mirroring the CQ button's FREE/FD subtitle. Options apply immediately, mid-hunt. Co-Authored-By: Claude Fable 5 * refactor: make Hunt tie-breaking explicit instead of relying on sort stability Per Copilot review on #699: append an explicit HuntCandidate.index tie-breaker to every priority comparator and pick with minWithOrNull, so the freshest-decode tie-break is a contract of the comparator (index is unique => total order) rather than an artifact of sortedWith stability, and no ranked copy of the pool is allocated per decode cycle. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../java/com/k1af/ft8af/GeneralVariables.java | 6 + .../com/k1af/ft8af/database/DatabaseOpr.java | 10 + .../ft8af/ft8transmit/FT8TransmitSignal.java | 16 +- .../kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt | 118 +++++-- .../ks3ckc/ft8af/hunt/HuntTargetSelector.kt | 192 +++++++++++ .../ft8af/ui/components/HuntOptionsSheet.kt | 302 ++++++++++++++++++ .../ks3ckc/ft8af/ui/components/TxStrip.kt | 86 ++++- .../src/main/res/values/strings_compose.xml | 27 ++ .../ft8af/hunt/HuntTargetSelectorTest.kt | 288 +++++++++++++++++ .../ui/components/HuntOptionsLogicTest.kt | 56 ++++ 10 files changed, 1054 insertions(+), 47 deletions(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/hunt/HuntTargetSelector.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/HuntOptionsSheet.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/hunt/HuntTargetSelectorTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/HuntOptionsLogicTest.kt diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 1b08722c7..4d8a93825 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -562,6 +562,12 @@ public static String excludedBandsToCsv() { // volatile: written from the Compose UI thread (DecodeScreen) and read from the // transmit/decode processing thread (FT8TransmitSignal), like zoneMapReady above. public static volatile boolean huntPotaOnly = false;//Mirror of the "CQ POTA" decode filter: Hunt only calls POTA CQs (issue #333) + // Hunt options (sheet on the HUNT button). volatile: written from the Compose UI + // thread, read from the transmit/decode processing thread (like huntPotaOnly). + public static volatile String huntPriority = "LATEST";//HuntPriority enum name: which CQ Hunt answers first + public static volatile boolean huntAvoidPileups = false;//Hunt: prefer CQs no one else is answering + public static final int HUNT_MIN_SNR_OFF = -999;//sentinel: min-SNR floor disabled + public static volatile int huntMinSnr = HUNT_MIN_SNR_OFF;//Hunt: skip CQs below this SNR (dB) public static boolean autoCallFollow = true;//Auto-call followed callsigns // volatile: written from the Compose settings UI thread and read from the // transmit thread (FT8TransmitSignal.dequeueNextCaller), like huntPotaOnly above. diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 64e05b65f..026f5381e 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2716,6 +2716,16 @@ protected Void doInBackground(Void... voids) { if (name.equalsIgnoreCase("autoFollowCQ")) {//Auto-follow CQ GeneralVariables.autoFollowCQ = result.equals("1"); } + if (name.equalsIgnoreCase("huntPriority")) {//Hunt target priority (HuntPriority enum name) + GeneralVariables.huntPriority = result; + } + if (name.equalsIgnoreCase("huntAvoidPileups")) {//Hunt: prefer CQs no one else is answering + GeneralVariables.huntAvoidPileups = result.equals("1"); + } + if (name.equalsIgnoreCase("huntMinSnr")) {//Hunt: min-SNR floor (dB), HUNT_MIN_SNR_OFF = off + GeneralVariables.huntMinSnr = + parseConfigInt(result, GeneralVariables.HUNT_MIN_SNR_OFF); + } if (name.equalsIgnoreCase("huntCallsCQ")) {//Hunt+CQ hybrid GeneralVariables.huntCallsCQ = result.equals("1"); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java index 9fc2cdd3d..5b5789c5f 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java @@ -1480,6 +1480,12 @@ private boolean checkCQMeOrFollowCQMessage(ArrayList messages, boole // just abandoned by pressing CQ), which made auto-answer "randomly" lock // onto an inactive station and call it forever. Live decodes only, matching // the "calling me" loops. + // + // All qualifying CQs are collected (most-recent-first) and the Hunt options + // (priority, pileup avoidance, min-SNR floor) decide which one to answer via + // HuntTargetSelector — LATEST with no filters reproduces the historical + // first-qualifying-match behavior. + ArrayList eligible = new ArrayList<>(); for (int i = messages.size() - 1; i >= 0; i--) { Ft8Message msg = messages.get(i); if (isExcludeMessage(msg)) continue;// check if this is an excluded message @@ -1500,9 +1506,17 @@ && mayAutoCall(GeneralVariables.autoFollowCQ, GeneralVariables.autoCallFollow, || GeneralVariables.directionalCQIsForMe(msg.callsignTo)) && !GeneralVariables.checkQSLCallsign(msg.getCallsignFrom())// not previously contacted successfully && !GeneralVariables.checkIsMyCallsign(msg.callsignFrom)) {// not myself + eligible.add(msg); + } + } + if (!eligible.isEmpty()) { + Ft8Message msg = radio.ks3ckc.ft8af.hunt.HuntTargetSelector.pick(eligible, messages); + if (msg != null) { GeneralVariables.fileLog("QSO: auto-follow CQ from " + msg.getCallsignFrom() - + " (Hunt=" + GeneralVariables.autoFollowCQ + ")"); + + " (Hunt=" + GeneralVariables.autoFollowCQ + + ", priority=" + GeneralVariables.huntPriority + + ", candidates=" + eligible.size() + ")"); resetTargetReport(); setTransmit(new TransmitCallsign(msg.i3, msg.n3, msg.getCallsignFrom(), msg.freq_hz , msg.getSequence(), msg.hasSnr() ? msg.snr : 0), 1, msg.extraInfo); diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt index c6772f89e..d138b946b 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt @@ -47,9 +47,12 @@ import radio.ks3ckc.ft8af.ui.components.CqOptionsSheet import radio.ks3ckc.ft8af.ui.components.canEnableFieldDay import radio.ks3ckc.ft8af.ui.components.shouldPersistFreeText import radio.ks3ckc.ft8af.ui.components.shouldPersistSection +import radio.ks3ckc.ft8af.hunt.huntPriorityFromConfig import radio.ks3ckc.ft8af.ui.components.FT8AFTab import radio.ks3ckc.ft8af.ui.components.FrequencyPickerSheet import radio.ks3ckc.ft8af.ui.components.HoundSetupSheet +import radio.ks3ckc.ft8af.ui.components.HuntOptionsSheet +import radio.ks3ckc.ft8af.ui.components.huntStripSubtitle import radio.ks3ckc.ft8af.ui.components.formatMhz import radio.ks3ckc.ft8af.ui.components.QsoCelebration import radio.ks3ckc.ft8af.ui.components.SlotTimerBar @@ -144,6 +147,52 @@ fun FT8AFApp(mainViewModel: MainViewModel) { // editable in Settings, which provides the persisted default at startup). var huntEnabled by remember { mutableStateOf(GeneralVariables.autoFollowCQ) } + // Hunt Options sheet state — the notch on (or a long-press of) the HUNT + // button opens it. Mirrors the GeneralVariables.hunt* statics the transmit + // engine's HuntTargetSelector reads each decode cycle. + var showHuntOptions by remember { mutableStateOf(false) } + var huntPriority by remember { + mutableStateOf(huntPriorityFromConfig(GeneralVariables.huntPriority)) + } + var huntAvoidPileups by remember { mutableStateOf(GeneralVariables.huntAvoidPileups) } + var huntMinSnr by remember { + mutableStateOf( + GeneralVariables.huntMinSnr.takeIf { it != GeneralVariables.HUNT_MIN_SNR_OFF }, + ) + } + + // Turn Hunt on/off — shared by the TX-strip HUNT toggle and the Hunt options + // sheet's Start button so both arm the sequencer identically. + val setHuntEnabled: (Boolean) -> Unit = setHunt@{ newVal -> + if (newVal && GeneralVariables.myCallsign.isNullOrEmpty()) { + // Hunt transmits replies, so it needs a callsign just like CQ does. + Toast.makeText(context, context.getString(R.string.app_set_callsign_first), Toast.LENGTH_SHORT).show() + return@setHunt + } + huntEnabled = newVal + GeneralVariables.autoFollowCQ = newVal + mainViewModel.databaseOpr.writeConfig( + "autoFollowCQ", if (newVal) "1" else "0", null, + ) + if (newVal) { + // Arm the sequencer so Hunt actually answers CQs. It stays silent + // (transmit path suppresses calling CQ) until it hears a CQ to work. + // armForHunt (not userResetToCQ) so Hunt can answer on the very next + // cycle instead of skipping one via pendingUserCQ. + mainViewModel.ft8TransmitSignal.armForHunt() + mainViewModel.ft8TransmitSignal.setActivated(true) + GeneralVariables.resetLaunchSupervision() + } else { + mainViewModel.ft8TransmitSignal.setActivated(false) + } + Toast.makeText( + context, + if (newVal) context.getString(R.string.app_hunt_on) + else context.getString(R.string.app_hunt_off), + Toast.LENGTH_SHORT, + ).show() + } + // DXpedition Hound mode. Mirrors GeneralVariables.houndMode; the setup sheet // collects the Fox call + call frequency before starting. var dxEnabled by remember { mutableStateOf(GeneralVariables.houndMode) } @@ -184,6 +233,11 @@ fun FT8AFApp(mainViewModel: MainViewModel) { // chip reflects the correct state even when arming is skipped // (e.g. callsign not yet configured). huntEnabled = GeneralVariables.autoFollowCQ + // Hunt options load with the rest of the config, after first composition. + huntPriority = huntPriorityFromConfig(GeneralVariables.huntPriority) + huntAvoidPileups = GeneralVariables.huntAvoidPileups + huntMinSnr = GeneralVariables.huntMinSnr + .takeIf { it != GeneralVariables.HUNT_MIN_SNR_OFF } // Config (incl. the saved custom CQ) loads after first composition, so // pull it in once it's ready and seed the field if untouched. savedCqFreeText = GeneralVariables.cqFreeText ?: "" @@ -382,6 +436,7 @@ fun FT8AFApp(mainViewModel: MainViewModel) { cqModifier = cqModifier, isFreeTextMode = isFreeTextMode, fieldDayEnabled = fieldDayEnabled, + huntPriorityTag = huntStripSubtitle(huntPriority), isTuning = isTuning, tuneRemainingSec = tuneRemainingSec, onToggleTune = { @@ -472,36 +527,8 @@ fun FT8AFApp(mainViewModel: MainViewModel) { mainViewModel.ft8TransmitSignal.userResetToCQ() } }, - onToggleHunt = { - val newVal = !huntEnabled - if (newVal && GeneralVariables.myCallsign.isNullOrEmpty()) { - // Hunt transmits replies, so it needs a callsign just like CQ does. - Toast.makeText(context, context.getString(R.string.app_set_callsign_first), Toast.LENGTH_SHORT).show() - } else { - huntEnabled = newVal - GeneralVariables.autoFollowCQ = newVal - mainViewModel.databaseOpr.writeConfig( - "autoFollowCQ", if (newVal) "1" else "0", null, - ) - if (newVal) { - // Arm the sequencer so Hunt actually answers CQs. It stays silent - // (transmit path suppresses calling CQ) until it hears a CQ to work. - // armForHunt (not userResetToCQ) so Hunt can answer on the very next - // cycle instead of skipping one via pendingUserCQ. - mainViewModel.ft8TransmitSignal.armForHunt() - mainViewModel.ft8TransmitSignal.setActivated(true) - GeneralVariables.resetLaunchSupervision() - } else { - mainViewModel.ft8TransmitSignal.setActivated(false) - } - Toast.makeText( - context, - if (newVal) context.getString(R.string.app_hunt_on) - else context.getString(R.string.app_hunt_off), - Toast.LENGTH_SHORT, - ).show() - } - }, + onToggleHunt = { setHuntEnabled(!huntEnabled) }, + onLongPressHunt = { showHuntOptions = true }, onCycleMode = { // Cycle through the shipped ModeProfile entries in declaration order // (FT8 -> FT4 -> FT2 -> ...), wrapping around. An unknown current mode @@ -712,5 +739,36 @@ fun FT8AFApp(mainViewModel: MainViewModel) { } }, ) + + // Hunt Options — target priority + smart filters. Selections apply + // immediately (the engine reads the GeneralVariables.hunt* mirrors every + // decode cycle), so mid-hunt changes take effect on the next cycle. + HuntOptionsSheet( + visible = showHuntOptions, + huntEnabled = huntEnabled, + priority = huntPriority, + avoidPileups = huntAvoidPileups, + minSnrDb = huntMinSnr, + onDismiss = { showHuntOptions = false }, + onSelectPriority = { p -> + huntPriority = p + GeneralVariables.huntPriority = p.name + mainViewModel.databaseOpr.writeConfig("huntPriority", p.name, null) + }, + onAvoidPileupsChange = { enabled -> + huntAvoidPileups = enabled + GeneralVariables.huntAvoidPileups = enabled + mainViewModel.databaseOpr.writeConfig( + "huntAvoidPileups", if (enabled) "1" else "0", null, + ) + }, + onMinSnrChange = { floor -> + huntMinSnr = floor + val stored = floor ?: GeneralVariables.HUNT_MIN_SNR_OFF + GeneralVariables.huntMinSnr = stored + mainViewModel.databaseOpr.writeConfig("huntMinSnr", stored.toString(), null) + }, + onStartHunt = { setHuntEnabled(true) }, + ) } } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/hunt/HuntTargetSelector.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/hunt/HuntTargetSelector.kt new file mode 100644 index 000000000..29fa0708a --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/hunt/HuntTargetSelector.kt @@ -0,0 +1,192 @@ +package radio.ks3ckc.ft8af.hunt + +import com.k1af.ft8af.Ft8Message +import com.k1af.ft8af.GeneralVariables +import com.k1af.ft8af.maidenhead.MaidenheadGrid +import radio.ks3ckc.ft8af.pota.PotaCqClassifier +import radio.ks3ckc.ft8af.pota.PotaSpotsRepository + +/** + * How Hunt picks which qualifying CQ to answer when several are decoded in the + * same cycle. [LATEST] is the historical behavior (most recent decode wins). + */ +enum class HuntPriority { + LATEST, + STRONGEST, + WEAKEST, + FARTHEST, + POTA_FIRST, + NEW_DXCC_FIRST, + NEW_GRID_FIRST, +} + +/** + * Parse the persisted config value back to a [HuntPriority]. Unknown/blank/null + * values (fresh install, downgrade, hand-edited config) fall back to [HuntPriority.LATEST] + * so Hunt never breaks on a bad stored string. + */ +fun huntPriorityFromConfig(value: String?): HuntPriority = + HuntPriority.entries.firstOrNull { it.name.equals(value?.trim(), ignoreCase = true) } + ?: HuntPriority.LATEST + +/** + * One eligible CQ caller, reduced to the facts the ranking needs. Pure data so + * [selectHuntCandidate] can be unit-tested without Android. + * + * @param index position in the eligible list; the list is most-recent-first, + * so a smaller index means a fresher decode + * @param snr decoder SNR in dB, or null when the decoder didn't set one + * @param distanceKm great-circle distance to the caller, or null when unknown + * @param isActivator the CQ is a POTA/SOTA activation + * @param isNewPark the activation is from a park not yet in the hunted log + * @param isNewDxcc the caller's DXCC entity hasn't been worked + * @param isNewGrid the caller's grid square hasn't been worked + * @param pileupCalls how many other stations were heard answering this caller + * in the same decode window + */ +data class HuntCandidate( + val index: Int, + val snr: Int? = null, + val distanceKm: Double? = null, + val isActivator: Boolean = false, + val isNewPark: Boolean = false, + val isNewDxcc: Boolean = false, + val isNewGrid: Boolean = false, + val pileupCalls: Int = 0, +) + +/** + * Whether a candidate clears the minimum-signal floor. A null floor disables the + * check; a candidate with no SNR passes (the decoder occasionally omits SNR, and + * silently dropping those stations would make Hunt look randomly deaf). + */ +internal fun passesSnrFloor(snr: Int?, minSnrDb: Int?): Boolean = + minSnrDb == null || snr == null || snr >= minSnrDb + +/** POTA_FIRST tier: unhunted park > any activation > everyone else. */ +internal fun activatorRank(c: HuntCandidate): Int = when { + c.isNewPark -> 2 + c.isActivator -> 1 + else -> 0 +} + +/** + * Pick the best CQ to answer from this cycle's eligible callers. + * + * The floor is a hard filter (a too-weak CQ is never answered — better to wait a + * cycle than start a QSO that fizzles). Pileup avoidance is a soft preference: + * candidates nobody else is answering win, but when every caller has a pileup we + * still pick one rather than sit silent. Ties always go to the freshest decode: + * the input is most-recent-first and every comparator ends with an explicit + * [HuntCandidate.index] tie-breaker, so the winner never depends on sort stability. + */ +internal fun selectHuntCandidate( + candidates: List, + priority: HuntPriority, + avoidPileups: Boolean, + minSnrDb: Int?, +): HuntCandidate? { + val floored = candidates.filter { passesSnrFloor(it.snr, minSnrDb) } + if (floored.isEmpty()) return null + val pool = if (avoidPileups) { + floored.filter { it.pileupCalls == 0 }.ifEmpty { floored } + } else { + floored + } + val comparator: Comparator = when (priority) { + HuntPriority.LATEST -> return pool.first() + HuntPriority.STRONGEST -> compareByDescending { it.snr ?: Int.MIN_VALUE } + // For WEAKEST an unknown SNR must not win the "weakest" crown, so it sorts last. + HuntPriority.WEAKEST -> compareBy { it.snr ?: Int.MAX_VALUE } + HuntPriority.FARTHEST -> compareByDescending { it.distanceKm ?: -1.0 } + HuntPriority.POTA_FIRST -> + compareByDescending { activatorRank(it) } + .thenByDescending { it.snr ?: Int.MIN_VALUE } + HuntPriority.NEW_DXCC_FIRST -> + compareByDescending { it.isNewDxcc } + .thenByDescending { it.snr ?: Int.MIN_VALUE } + HuntPriority.NEW_GRID_FIRST -> + compareByDescending { it.isNewGrid } + .thenByDescending { it.snr ?: Int.MIN_VALUE } + } + // index is unique, so the composed comparator is a total order: the freshest + // decode wins ties by contract, not by sort stability. minWithOrNull also skips + // sorting/allocating a ranked copy of the pool every decode cycle. + return pool.minWithOrNull(comparator.thenBy { it.index }) +} + +/** + * Count the other stations heard answering [cqCallsign] in the same decode + * window: any non-CQ message directed at the caller counts as a competitor. + */ +internal fun countRepliesTo(cqCallsign: String?, messages: List): Int { + if (cqCallsign.isNullOrEmpty()) return 0 + return messages.count { m -> + !m.checkIsCQ() && cqCallsign.equals(m.callsignTo, ignoreCase = true) + } +} + +/** + * Bridge for the Java transmit engine: rank this cycle's eligible CQ callers by + * the operator's Hunt options and return the one to answer. + * + * The engine keeps its existing eligibility filters (is CQ, not worked, not + * excluded, POTA-only filter, directional-CQ respect, …) and hands over only the + * survivors; this decides *which* survivor gets the call, replacing the old + * take-the-most-recent behavior (which [HuntPriority.LATEST] reproduces). + */ +object HuntTargetSelector { + + @JvmStatic + fun pick(eligible: List, allMessages: List): Ft8Message? { + if (eligible.isEmpty()) return null + val priority = huntPriorityFromConfig(GeneralVariables.huntPriority) + val avoidPileups = GeneralVariables.huntAvoidPileups + val minSnr = GeneralVariables.huntMinSnr + .takeIf { it != GeneralVariables.HUNT_MIN_SNR_OFF } + + // Nothing to rank: default priority with no filters active picks the most + // recent caller without paying for distance/park/pileup lookups per cycle. + if (priority == HuntPriority.LATEST && !avoidPileups && minSnr == null) { + return eligible.first() + } + + val myGrid = GeneralVariables.getMyMaidenheadGrid() + val candidates = eligible.mapIndexed { index, msg -> + // Only resolve the facts the active options actually rank on — the + // park-spot map, worked-grid set, and distance math run every decode + // cycle otherwise. + val parkRef = if (priority == HuntPriority.POTA_FIRST) { + PotaSpotsRepository.parkRefFor(msg.callsignFrom) + } else null + HuntCandidate( + index = index, + snr = if (msg.hasSnr()) msg.snr else null, + distanceKm = if (priority == HuntPriority.FARTHEST) { + distanceKmTo(myGrid, msg.maidenGrid) + } else null, + isActivator = priority == HuntPriority.POTA_FIRST && + (PotaCqClassifier.isPotaCq(msg) || msg.modifier == "SOTA"), + isNewPark = parkRef != null && !GeneralVariables.checkQSLPark(parkRef), + isNewDxcc = msg.fromDxcc, + isNewGrid = priority == HuntPriority.NEW_GRID_FIRST && + isUnworkedGrid(msg.maidenGrid), + pileupCalls = if (avoidPileups) { + countRepliesTo(msg.callsignFrom, allMessages) + } else 0, + ) + } + val chosen = selectHuntCandidate(candidates, priority, avoidPileups, minSnr) + ?: return null + return eligible[chosen.index] + } + + private fun distanceKmTo(myGrid: String?, theirGrid: String?): Double? { + if (myGrid.isNullOrEmpty() || theirGrid.isNullOrEmpty()) return null + // getDist returns 0 for an unparseable grid; treat that as unknown. + return MaidenheadGrid.getDist(myGrid, theirGrid).takeIf { it > 0 } + } + + private fun isUnworkedGrid(grid: String?): Boolean = + !grid.isNullOrEmpty() && grid.length >= 4 && !GeneralVariables.checkQSLGrid(grid) +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/HuntOptionsSheet.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/HuntOptionsSheet.kt new file mode 100644 index 000000000..6540cdb52 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/HuntOptionsSheet.kt @@ -0,0 +1,302 @@ +package radio.ks3ckc.ft8af.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.k1af.ft8af.R +import radio.ks3ckc.ft8af.hunt.HuntPriority +import radio.ks3ckc.ft8af.theme.* + +// ---- Pure logic (extracted for unit testing) ---- + +/** + * The min-SNR floor choices offered in the sheet; null = no floor. Values follow + * the practical FT8 range: −10 dB is "armchair copy", −20 dB is near the decode + * limit where a completed QSO becomes a coin flip. + */ +internal val HUNT_MIN_SNR_CHOICES: List = listOf(null, -10, -15, -20) + +/** + * The short tag shown on the HUNT button while a non-default priority is armed + * (mirrors the CQ button's FREE/FD subtitle). Null for the default so the + * button stays clean when Hunt behaves classically. + */ +internal fun huntStripSubtitle(priority: HuntPriority): String? = when (priority) { + HuntPriority.LATEST -> null + HuntPriority.STRONGEST -> "STRONG" + HuntPriority.WEAKEST -> "WEAK" + HuntPriority.FARTHEST -> "DX" + HuntPriority.POTA_FIRST -> "POTA" + HuntPriority.NEW_DXCC_FIRST -> "DXCC" + HuntPriority.NEW_GRID_FIRST -> "GRID" +} + +/** Chip label for a min-SNR choice, e.g. "−15 dB"; null (= off) is localized by the caller. */ +internal fun huntMinSnrChipLabel(minSnrDb: Int): String = "−${-minSnrDb} dB" + +// ---- Composable ---- + +@Composable +private fun priorityChipLabel(priority: HuntPriority): String = stringResource( + when (priority) { + HuntPriority.LATEST -> R.string.hunt_priority_latest + HuntPriority.STRONGEST -> R.string.hunt_priority_strongest + HuntPriority.WEAKEST -> R.string.hunt_priority_weakest + HuntPriority.FARTHEST -> R.string.hunt_priority_farthest + HuntPriority.POTA_FIRST -> R.string.hunt_priority_pota + HuntPriority.NEW_DXCC_FIRST -> R.string.hunt_priority_new_dxcc + HuntPriority.NEW_GRID_FIRST -> R.string.hunt_priority_new_grid + }, +) + +@Composable +private fun priorityDescription(priority: HuntPriority): String = stringResource( + when (priority) { + HuntPriority.LATEST -> R.string.hunt_priority_desc_latest + HuntPriority.STRONGEST -> R.string.hunt_priority_desc_strongest + HuntPriority.WEAKEST -> R.string.hunt_priority_desc_weakest + HuntPriority.FARTHEST -> R.string.hunt_priority_desc_farthest + HuntPriority.POTA_FIRST -> R.string.hunt_priority_desc_pota + HuntPriority.NEW_DXCC_FIRST -> R.string.hunt_priority_desc_new_dxcc + HuntPriority.NEW_GRID_FIRST -> R.string.hunt_priority_desc_new_grid + }, +) + +/** + * Hunt options — opened from the notch on the HUNT button (mirroring the CQ + * options sheet). Lets the operator pick which CQ Hunt answers first when + * several are decoded in the same cycle, plus two "smart filter" preferences + * (pileup avoidance, minimum-signal floor). + */ +@Composable +fun HuntOptionsSheet( + visible: Boolean, + huntEnabled: Boolean, + priority: HuntPriority, + avoidPileups: Boolean, + minSnrDb: Int?, + onDismiss: () -> Unit, + onSelectPriority: (HuntPriority) -> Unit, + onAvoidPileupsChange: (Boolean) -> Unit, + onMinSnrChange: (Int?) -> Unit, + onStartHunt: () -> Unit, +) { + FT8AFBottomSheet( + visible = visible, + onDismiss = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + ) { + // ---- Section: HUNT PRIORITY ---- + SheetSectionHeader(stringResource(R.string.hunt_priority_title)) + + Spacer(modifier = Modifier.height(8.dp)) + + val chipShape = RoundedCornerShape(999.dp) + val scrollState = rememberScrollState() + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (p in HuntPriority.entries) { + val isSelected = priority == p + Row( + modifier = Modifier + .height(32.dp) + .clip(chipShape) + .background(if (isSelected) AccentSoft else BgSurface2, chipShape) + .border(1.dp, if (isSelected) BorderAmber else Border, chipShape) + .clickable { onSelectPriority(p) } + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = priorityChipLabel(p), + color = if (isSelected) Accent else TextMuted, + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + fontFamily = GeistMonoFamily, + letterSpacing = 0.02.sp, + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // What the selected priority actually does — keeps the short chip + // labels honest without cluttering the row. + Text( + text = priorityDescription(priority), + color = TextFaint, + fontSize = 11.sp, + fontFamily = GeistMonoFamily, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // ---- Section: SMART FILTERS ---- + SheetSectionHeader(stringResource(R.string.hunt_filters_title)) + + Spacer(modifier = Modifier.height(4.dp)) + + // Avoid pileups + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.hunt_avoid_pileups), + color = TextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + ) + Text( + text = stringResource(R.string.hunt_avoid_pileups_desc), + color = TextFaint, + fontSize = 11.sp, + fontFamily = GeistMonoFamily, + ) + } + Switch( + checked = avoidPileups, + onCheckedChange = onAvoidPileupsChange, + colors = SwitchDefaults.colors( + checkedThumbColor = Accent, + checkedTrackColor = AccentSoft, + uncheckedThumbColor = TextMuted, + uncheckedTrackColor = BgSurface3, + ), + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Min-signal floor + Text( + text = stringResource(R.string.hunt_min_snr), + color = TextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + ) + Text( + text = stringResource(R.string.hunt_min_snr_desc), + color = TextFaint, + fontSize = 11.sp, + fontFamily = GeistMonoFamily, + ) + Spacer(modifier = Modifier.height(6.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (choice in HUNT_MIN_SNR_CHOICES) { + val isSelected = minSnrDb == choice + Row( + modifier = Modifier + .height(32.dp) + .clip(chipShape) + .background(if (isSelected) AccentSoft else BgSurface2, chipShape) + .border(1.dp, if (isSelected) BorderAmber else Border, chipShape) + .clickable { onMinSnrChange(choice) } + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = choice?.let { huntMinSnrChipLabel(it) } + ?: stringResource(R.string.hunt_min_snr_off), + color = if (isSelected) Accent else TextMuted, + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + fontFamily = GeistMonoFamily, + letterSpacing = 0.02.sp, + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(R.string.hunt_footer_note), + color = TextFaint, + fontSize = 10.sp, + fontFamily = GeistMonoFamily, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // ---- Action button: start Hunt with these options (or just close) ---- + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Accent) + .clickable { + if (!huntEnabled) onStartHunt() + onDismiss() + } + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + FT8AFIcons.Target(size = 18.dp, color = BgApp, strokeWidth = 1.8f) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (huntEnabled) stringResource(R.string.hunt_done) + else stringResource(R.string.hunt_start), + color = BgApp, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + fontFamily = GeistMonoFamily, + letterSpacing = 0.04.sp, + ) + } + } + } +} + +@Composable +private fun SheetSectionHeader(text: String) { + Text( + text = text, + color = TextMuted, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + letterSpacing = 0.08.sp, + modifier = Modifier.padding(top = 8.dp), + ) +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt index ae275cd69..8f4541da3 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/TxStrip.kt @@ -119,6 +119,7 @@ fun TxStrip( cqModifier: String = "", isFreeTextMode: Boolean = false, fieldDayEnabled: Boolean = false, + huntPriorityTag: String? = null, isTuning: Boolean = false, tuneRemainingSec: Int = 0, onToggleTune: () -> Unit = {}, @@ -127,6 +128,7 @@ fun TxStrip( onCallCQ: () -> Unit, onStop: () -> Unit, onLongPressCQ: () -> Unit = {}, + onLongPressHunt: () -> Unit = {}, onToggleSlot: () -> Unit, onToggleHunt: () -> Unit, onCycleMode: () -> Unit, @@ -297,9 +299,13 @@ fun TxStrip( verticalAlignment = Alignment.CenterVertically, ) { // HUNT — stacked icon + label. Locked off during an active CQ/QSO. + // The notch (and a long-press) opens the Hunt options sheet; the + // subtitle tag mirrors the armed non-default priority (like the CQ + // button's FREE/FD subtitle). StackedActionButton( modifier = Modifier.weight(1f), label = stringResource(R.string.tx_hunt), + subtitle = huntPriorityTag, background = when { actions.huntDisabled -> BgSurface3.copy(alpha = 0.4f) actions.huntActive -> Signal.copy(alpha = 0.18f) @@ -313,6 +319,8 @@ fun TxStrip( borderColor = if (actions.huntActive) Signal.copy(alpha = 0.5f) else Border, enabled = !actions.huntDisabled, onClick = onToggleHunt, + onLongClick = onLongPressHunt, + optionsContentDescription = stringResource(R.string.tx_hunt_options), ) { color -> FT8AFIcons.Target(size = 18.dp, color = color, strokeWidth = 1.8f) } // CQ / STOP — the primary action. Filled amber to call CQ, red to stop. @@ -469,6 +477,7 @@ private fun TxChip( } /** A large secondary button with the icon stacked above the label (HUNT, TX slot). */ +@OptIn(ExperimentalFoundationApi::class) @Composable private fun StackedActionButton( label: String, @@ -478,30 +487,75 @@ private fun StackedActionButton( enabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + subtitle: String? = null, + onLongClick: (() -> Unit)? = null, + optionsContentDescription: String = "", icon: @Composable (Color) -> Unit, ) { - Column( + Row( modifier = modifier .height(54.dp) .clip(RoundedCornerShape(12.dp)) .background(background) .border(1.dp, borderColor, RoundedCornerShape(12.dp)) - .clickable(enabled = enabled) { onClick() } - .padding(vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(3.dp, Alignment.CenterVertically), + .combinedClickable( + enabled = enabled, + onClick = onClick, + onLongClick = onLongClick, + ) + .padding(horizontal = 4.dp, vertical = if (subtitle != null) 4.dp else 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - icon(contentColor) - Text( - text = label, - color = contentColor, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = GeistMonoFamily, - letterSpacing = 0.02.sp, - maxLines = 1, - softWrap = false, - ) + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + if (subtitle != null) 1.dp else 3.dp, Alignment.CenterVertically, + ), + ) { + icon(contentColor) + Text( + text = label, + color = contentColor, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + letterSpacing = 0.02.sp, + maxLines = 1, + softWrap = false, + ) + if (subtitle != null) { + Text( + text = subtitle, + color = contentColor.copy(alpha = 0.6f), + fontSize = 8.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + letterSpacing = 0.02.sp, + maxLines = 1, + softWrap = false, + ) + } + } + if (onLongClick != null) { + // Same visible "more options" affordance as the CQ button — a plain + // tap opens the options sheet (discoverable), long-press on the whole + // button is the shortcut. + Box( + modifier = Modifier + .size(width = 22.dp, height = 38.dp) + .clip(RoundedCornerShape(8.dp)) + .background(contentColor.copy(alpha = 0.16f)) + .clickable(enabled = enabled, onClick = onLongClick) + .semantics { + role = Role.Button + contentDescription = optionsContentDescription + }, + contentAlignment = Alignment.Center, + ) { + FT8AFIcons.ChevronDown(size = 14.dp, color = contentColor, strokeWidth = 2.2f) + } + } } } diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 12f056b79..3189dcddd 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -117,6 +117,33 @@ Decrease TX volume Increase TX volume CQ message options (modifier, free text, Field Day) + Hunt options (priority, smart filters) + + + HUNT PRIORITY + Latest + Strongest + Weakest + Farthest + POTA/SOTA + New DXCC + New grid + Answer the most recently decoded CQ (classic behavior) + Answer the loudest CQ first — best odds of completing + Answer the weakest CQ first — QRP and challenge hunting + Answer the most distant CQ first — chase DX + Answer park/summit activators first — parks you haven\'t hunted rank highest + Answer CQs from DXCC entities you haven\'t worked first + Answer CQs from grid squares you haven\'t worked first + SMART FILTERS + Avoid pileups + Prefer CQs no one else is answering this cycle + Minimum signal + Skip CQs weaker than this — fewer QSOs that fizzle out + Off + Hunt always skips stations you\'ve already worked and your excluded prefixes. + Start Hunt + Done CQ + text + call doesn\'t fit in one FT8 message (max 13 chars, limited character set) diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/hunt/HuntTargetSelectorTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/hunt/HuntTargetSelectorTest.kt new file mode 100644 index 000000000..b3bb7ccfa --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/hunt/HuntTargetSelectorTest.kt @@ -0,0 +1,288 @@ +package radio.ks3ckc.ft8af.hunt + +import com.google.common.truth.Truth.assertThat +import com.k1af.ft8af.Ft8Message +import org.junit.Test + +/** + * Coverage for the pure Hunt-options ranking logic in HuntTargetSelector.kt: + * config parsing, the min-SNR floor, pileup avoidance, and each [HuntPriority] + * mode including unknown-SNR/-distance handling and freshest-decode tie breaks. + * + * Plain JUnit: only the pure top-level functions are exercised (the + * HuntTargetSelector object bridge touches GeneralVariables/Android and is + * covered by the engine's on-device behavior). + */ +class HuntTargetSelectorTest { + + private fun candidate( + index: Int, + snr: Int? = null, + distanceKm: Double? = null, + isActivator: Boolean = false, + isNewPark: Boolean = false, + isNewDxcc: Boolean = false, + isNewGrid: Boolean = false, + pileupCalls: Int = 0, + ) = HuntCandidate(index, snr, distanceKm, isActivator, isNewPark, isNewDxcc, isNewGrid, pileupCalls) + + private fun select( + candidates: List, + priority: HuntPriority = HuntPriority.LATEST, + avoidPileups: Boolean = false, + minSnrDb: Int? = null, + ) = selectHuntCandidate(candidates, priority, avoidPileups, minSnrDb) + + // ---- huntPriorityFromConfig ---- + + @Test + fun configParse_roundTripsEveryEnumName() { + for (p in HuntPriority.entries) { + assertThat(huntPriorityFromConfig(p.name)).isEqualTo(p) + } + } + + @Test + fun configParse_isCaseInsensitiveAndTrims() { + assertThat(huntPriorityFromConfig(" strongest ")).isEqualTo(HuntPriority.STRONGEST) + } + + @Test + fun configParse_fallsBackToLatestOnBadValues() { + assertThat(huntPriorityFromConfig(null)).isEqualTo(HuntPriority.LATEST) + assertThat(huntPriorityFromConfig("")).isEqualTo(HuntPriority.LATEST) + assertThat(huntPriorityFromConfig("BOGUS")).isEqualTo(HuntPriority.LATEST) + } + + // ---- passesSnrFloor ---- + + @Test + fun snrFloor_disabledPassesEverything() { + assertThat(passesSnrFloor(-24, null)).isTrue() + assertThat(passesSnrFloor(null, null)).isTrue() + } + + @Test + fun snrFloor_filtersBelowAndKeepsAtOrAbove() { + assertThat(passesSnrFloor(-16, -15)).isFalse() + assertThat(passesSnrFloor(-15, -15)).isTrue() + assertThat(passesSnrFloor(3, -15)).isTrue() + } + + @Test + fun snrFloor_unknownSnrPasses() { + // The decoder occasionally omits SNR; those stations must not silently vanish. + assertThat(passesSnrFloor(null, -15)).isTrue() + } + + // ---- floor + empty handling ---- + + @Test + fun select_emptyListReturnsNull() { + assertThat(select(emptyList())).isNull() + } + + @Test + fun select_returnsNullWhenFloorEliminatesEveryone() { + val all = listOf(candidate(0, snr = -20), candidate(1, snr = -22)) + assertThat(select(all, minSnrDb = -15)).isNull() + } + + @Test + fun select_floorAppliesBeforePriority() { + val strongButFloored = candidate(0, snr = -20) + val weakerButPassing = candidate(1, snr = -10) + val picked = select( + listOf(strongButFloored, weakerButPassing), + priority = HuntPriority.WEAKEST, minSnrDb = -15, + ) + assertThat(picked).isEqualTo(weakerButPassing) + } + + // ---- LATEST (historical behavior) ---- + + @Test + fun latest_picksFirstCandidate_listIsMostRecentFirst() { + val newest = candidate(0, snr = -20) + val older = candidate(1, snr = 5) + assertThat(select(listOf(newest, older))).isEqualTo(newest) + } + + // ---- STRONGEST / WEAKEST ---- + + @Test + fun strongest_picksHighestSnr() { + val weak = candidate(0, snr = -18) + val loud = candidate(1, snr = 4) + assertThat(select(listOf(weak, loud), HuntPriority.STRONGEST)).isEqualTo(loud) + } + + @Test + fun strongest_unknownSnrLosesToAnyKnown() { + val unknown = candidate(0, snr = null) + val faint = candidate(1, snr = -24) + assertThat(select(listOf(unknown, faint), HuntPriority.STRONGEST)).isEqualTo(faint) + } + + @Test + fun weakest_picksLowestSnr() { + val loud = candidate(0, snr = 4) + val weak = candidate(1, snr = -18) + assertThat(select(listOf(loud, weak), HuntPriority.WEAKEST)).isEqualTo(weak) + } + + @Test + fun weakest_unknownSnrNeverWins() { + val unknown = candidate(0, snr = null) + val weak = candidate(1, snr = -5) + assertThat(select(listOf(unknown, weak), HuntPriority.WEAKEST)).isEqualTo(weak) + } + + @Test + fun strongest_tieGoesToFreshestDecode() { + val newer = candidate(0, snr = -3) + val older = candidate(1, snr = -3) + assertThat(select(listOf(newer, older), HuntPriority.STRONGEST)).isEqualTo(newer) + } + + @Test + fun tieBreak_usesIndexNotListPosition() { + // The tie-break is the explicit index comparator, not sort stability: even if + // the pool arrives out of freshness order, the smaller index still wins. + val older = candidate(3, snr = -3) + val newer = candidate(1, snr = -3) + assertThat(select(listOf(older, newer), HuntPriority.STRONGEST)).isEqualTo(newer) + } + + @Test + fun farthest_tieGoesToFreshestDecode() { + val newer = candidate(0, distanceKm = 5000.0) + val older = candidate(1, distanceKm = 5000.0) + assertThat(select(listOf(newer, older), HuntPriority.FARTHEST)).isEqualTo(newer) + } + + @Test + fun potaFirst_fullTieGoesToFreshestDecode() { + val newer = candidate(0, snr = -8, isActivator = true) + val older = candidate(1, snr = -8, isActivator = true) + assertThat(select(listOf(newer, older), HuntPriority.POTA_FIRST)).isEqualTo(newer) + } + + // ---- FARTHEST ---- + + @Test + fun farthest_picksLargestDistance() { + val near = candidate(0, distanceKm = 120.0) + val far = candidate(1, distanceKm = 8400.0) + assertThat(select(listOf(near, far), HuntPriority.FARTHEST)).isEqualTo(far) + } + + @Test + fun farthest_unknownDistanceLosesToAnyKnown() { + val unknown = candidate(0, distanceKm = null) + val near = candidate(1, distanceKm = 15.0) + assertThat(select(listOf(unknown, near), HuntPriority.FARTHEST)).isEqualTo(near) + } + + // ---- POTA_FIRST ---- + + @Test + fun potaFirst_newParkBeatsAnyActivatorBeatsPlainCq() { + val plain = candidate(0, snr = 10) + val activator = candidate(1, snr = -10, isActivator = true) + val newPark = candidate(2, snr = -20, isActivator = true, isNewPark = true) + assertThat(select(listOf(plain, activator, newPark), HuntPriority.POTA_FIRST)) + .isEqualTo(newPark) + assertThat(select(listOf(plain, activator), HuntPriority.POTA_FIRST)) + .isEqualTo(activator) + } + + @Test + fun potaFirst_snrBreaksTiesWithinTier() { + val faintActivator = candidate(0, snr = -15, isActivator = true) + val loudActivator = candidate(1, snr = 0, isActivator = true) + assertThat(select(listOf(faintActivator, loudActivator), HuntPriority.POTA_FIRST)) + .isEqualTo(loudActivator) + } + + @Test + fun activatorRank_tiers() { + assertThat(activatorRank(candidate(0, isActivator = true, isNewPark = true))).isEqualTo(2) + assertThat(activatorRank(candidate(0, isActivator = true))).isEqualTo(1) + assertThat(activatorRank(candidate(0))).isEqualTo(0) + } + + // ---- NEW_DXCC_FIRST / NEW_GRID_FIRST ---- + + @Test + fun newDxccFirst_newEntityBeatsLouderKnownEntity() { + val loudKnown = candidate(0, snr = 10) + val faintNew = candidate(1, snr = -19, isNewDxcc = true) + assertThat(select(listOf(loudKnown, faintNew), HuntPriority.NEW_DXCC_FIRST)) + .isEqualTo(faintNew) + } + + @Test + fun newDxccFirst_fallsBackToStrongestWhenNoNewEntity() { + val faint = candidate(0, snr = -12) + val loud = candidate(1, snr = -2) + assertThat(select(listOf(faint, loud), HuntPriority.NEW_DXCC_FIRST)).isEqualTo(loud) + } + + @Test + fun newGridFirst_newGridBeatsLouderKnownGrid() { + val loudKnown = candidate(0, snr = 7) + val faintNew = candidate(1, snr = -14, isNewGrid = true) + assertThat(select(listOf(loudKnown, faintNew), HuntPriority.NEW_GRID_FIRST)) + .isEqualTo(faintNew) + } + + // ---- Pileup avoidance ---- + + @Test + fun avoidPileups_prefersUncontestedCaller() { + val contested = candidate(0, snr = 10, pileupCalls = 3) + val clear = candidate(1, snr = -12, pileupCalls = 0) + assertThat(select(listOf(contested, clear), HuntPriority.STRONGEST, avoidPileups = true)) + .isEqualTo(clear) + } + + @Test + fun avoidPileups_fallsBackWhenEveryoneIsContested() { + // A soft preference: when every caller has a pileup, still hunt rather than sit idle. + val a = candidate(0, snr = -12, pileupCalls = 2) + val b = candidate(1, snr = 3, pileupCalls = 1) + assertThat(select(listOf(a, b), HuntPriority.STRONGEST, avoidPileups = true)) + .isEqualTo(b) + } + + @Test + fun avoidPileups_offIgnoresPileupCounts() { + val contested = candidate(0, snr = 10, pileupCalls = 5) + val clear = candidate(1, snr = -12, pileupCalls = 0) + assertThat(select(listOf(contested, clear), HuntPriority.STRONGEST)).isEqualTo(contested) + } + + // ---- countRepliesTo ---- + + @Test + fun countReplies_countsDirectedNonCqMessages() { + val messages = listOf( + Ft8Message("CQ", "W1ABC", "FN42"), // the CQer himself — not a reply + Ft8Message("W1ABC", "K2DEF", "-10"), // reply to W1ABC + Ft8Message("W1ABC", "N3GHI", "EM73"), // reply to W1ABC + Ft8Message("K9ZZZ", "W4JKL", "R-05"), // reply to someone else + ) + assertThat(countRepliesTo("W1ABC", messages)).isEqualTo(2) + assertThat(countRepliesTo("K9ZZZ", messages)).isEqualTo(1) + assertThat(countRepliesTo("NOBODY", messages)).isEqualTo(0) + } + + @Test + fun countReplies_isCaseInsensitiveAndNullSafe() { + val messages = listOf(Ft8Message("w1abc", "K2DEF", "-10")) + assertThat(countRepliesTo("W1ABC", messages)).isEqualTo(1) + assertThat(countRepliesTo(null, messages)).isEqualTo(0) + assertThat(countRepliesTo("", messages)).isEqualTo(0) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/HuntOptionsLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/HuntOptionsLogicTest.kt new file mode 100644 index 000000000..8c3fa7591 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/HuntOptionsLogicTest.kt @@ -0,0 +1,56 @@ +package radio.ks3ckc.ft8af.ui.components + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import radio.ks3ckc.ft8af.hunt.HuntPriority + +/** + * Coverage for the pure helpers extracted from HuntOptionsSheet.kt (the sheet's + * chip data and the HUNT-button subtitle tag), mirroring CqOptionsLogicTest. + */ +class HuntOptionsLogicTest { + + @Test + fun stripSubtitle_defaultPriorityShowsNothing() { + assertThat(huntStripSubtitle(HuntPriority.LATEST)).isNull() + } + + @Test + fun stripSubtitle_nonDefaultPrioritiesShowShortTags() { + assertThat(huntStripSubtitle(HuntPriority.STRONGEST)).isEqualTo("STRONG") + assertThat(huntStripSubtitle(HuntPriority.WEAKEST)).isEqualTo("WEAK") + assertThat(huntStripSubtitle(HuntPriority.FARTHEST)).isEqualTo("DX") + assertThat(huntStripSubtitle(HuntPriority.POTA_FIRST)).isEqualTo("POTA") + assertThat(huntStripSubtitle(HuntPriority.NEW_DXCC_FIRST)).isEqualTo("DXCC") + assertThat(huntStripSubtitle(HuntPriority.NEW_GRID_FIRST)).isEqualTo("GRID") + } + + @Test + fun stripSubtitle_everyPriorityIsCoveredAndFitsTheButton() { + // A new HuntPriority must get an explicit (short) tag decision — the + // stacked button clips anything longer than ~6 monospace chars. + for (p in HuntPriority.entries) { + val tag = huntStripSubtitle(p) + if (p == HuntPriority.LATEST) { + assertThat(tag).isNull() + } else { + assertThat(tag).isNotEmpty() + assertThat(tag!!.length).isAtMost(6) + } + } + } + + @Test + fun minSnrChoices_startWithOffAndDescend() { + assertThat(HUNT_MIN_SNR_CHOICES.first()).isNull() + val values = HUNT_MIN_SNR_CHOICES.filterNotNull() + assertThat(values).isEqualTo(values.sortedDescending()) + assertThat(values).isNotEmpty() + } + + @Test + fun minSnrChipLabel_formatsWithTypographicMinus() { + assertThat(huntMinSnrChipLabel(-10)).isEqualTo("−10 dB") + assertThat(huntMinSnrChipLabel(-20)).isEqualTo("−20 dB") + } +} From f8a527ef924b3a1720c0c460bce4509852aea311 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 31 Jul 2026 10:19:10 -0500 Subject: [PATCH 094/113] Swap the TX message mid-cycle when a late decode advances the QSO (#704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Swap the TX message mid-cycle when a late decode advances the QSO With early decode on, the fast pass delivers ~13.5s into the slot and the auto-sequencer keys up ~0.45s into the next one. The late full-slot pass (#363) then delivers its recovered decodes 0-3.5s into that next slot — after key-up. When one of those is the partner's reply, the sequencer advances the over a few hundred ms too late and we spend the whole cycle re-sending the message we had already sent. Measured on a real POTA activation (2026-07-30, 126 transmissions): 45 late-pass deliveries landed 0-3.5s into the slot, i.e. after the ~0.45s key-up. In the clearest case the late pass advanced order 3 -> 2 for K5UUT nine milliseconds after key-up; that cycle went out as a repeat of "K5UUT K1AF R-10" instead of the RR73 that would have completed the QSO. Sometimes the race is won instead — W0PPA's arrived 43ms before key-up — so which one you get is decided by milliseconds. The swap is free inside the audio slack. The waveform occupies slotMillis - audioSlackMillis (FT8: 12.64s of a 15s slot), so a restart anywhere within the slack still plays the new message COMPLETE and ends on the boundary; the receiver just sees it at a slightly larger DT, which every FT8 decoder searches anyway. Past the slack the new message could not fit without clipping its leading Costas array, so we let the original over finish and pick the change up next cycle as before. Two properties the implementation depends on: - PTT is NOT dropped for the swap. requestTxRestart() reuses the STOP cancel machinery to stop the writers mid-buffer, but leaves isTransmitting set and never fires onAfterTransmit; afterPlayAudio() takes an early exit that releases only the audio. Dropping and re-raising PTT would add the rig's key-up delay mid-transmission — on some rigs enough to miss the slack window entirely. - The guard reserves RESTART_HEADROOM_MS for the swap itself. The decision is made on the decode thread but playback restarts on the TX worker, so a swap approved at the very edge of the slack would begin playing past it and clip its own leading Costas array — reintroducing the exact defect the feature exists to avoid. The restart check sits outside parseMessageToFunction's body so every path that moves functionOrder is covered (RR73 reply, completion, give-up), not just the ones we remembered to instrument. Free text is excluded: its content doesn't depend on functionOrder, so a swap would replay the identical message. Co-Authored-By: Claude Opus 5 (1M context) * Address Copilot review: PTT strand on STOP, and uninterruptible playback paths Two real defects found in review. 1. afterPlayAudio() took the swap early-exit on txRestartPending alone. A STOP or deactivation landing between the swap being queued and the writers unwinding would therefore skip onAfterTransmit and LEAVE THE RIG KEYED, and leave txRestartPending set so the worker replayed an over the operator had just cancelled. The exit is now conditional on isTransmitting as well; the stop path falls through, clears the flag, and runs the real end-of-over teardown. The replay loop re-checks isTransmitting alongside consumeTxRestart() to close the last window. 2. The NETWORK and CAT-audio branches of playFT8Signal() never observe txAudioCancelled -- they spin on isTransmitting for up to 13.1s/13.0s -- and requestTxRestart() deliberately leaves isTransmitting set. A swap requested on those paths would not interrupt anything: it would sit queued until the wait expired and then replay ~13s into the slot, where the clip math strips nearly the whole message. That is worse than not swapping, so playbackSupportsMidCycleRestart() now refuses up front and the sequencer picks the change up next cycle as before. CAT *control* with sound-card audio stays restartable -- the CAT branch is only taken when the connector reports supportTransmitOverCAT(). Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../ft8af/ft8transmit/FT8TransmitSignal.java | 282 ++++++++++++++++-- .../ft8transmit/LateDecodeTxRestartTest.java | 196 ++++++++++++ 2 files changed, 452 insertions(+), 26 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/LateDecodeTxRestartTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java index 5b5789c5f..be900db06 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java @@ -121,6 +121,19 @@ public class FT8TransmitSignal { // sound-card playback. private volatile boolean txAudioCancelled = false; + // Mid-cycle message swap (the late-decode TX race). Set by requestTxRestart() + // when the late full-slot pass advances the QSO after we have already keyed up + // with the previous over's message. The playback loops break on txAudioCancelled + // exactly as they do for a STOP, but afterPlayAudio() sees this flag and tears + // down ONLY the audio — PTT stays keyed — and DoTransmitRunnable then replays + // the newly chosen message inside the same over. Consumed by consumeTxRestart(). + private volatile boolean txRestartPending = false; + + // functionOrder captured at key-up, so an evidence-only (deep/late) pass can tell + // whether it actually changed what we should be sending. Read/written from the + // decode thread and the TX worker. + private volatile int orderAtKeyUp = -1; + public UtcTimer utcTimer; @@ -343,6 +356,97 @@ static ManualTxGate decideManualTx(long msInCycle, int audioSlackMillis, return new ManualTxGate(transmit, (int) clip); } + /** + * Time reserved for the swap itself: regenerating the waveform for the new message + * and getting the first samples to the sound card. The restart decision is made on + * the decode thread but playback restarts on the TX worker, so the guard must leave + * room for that hop — otherwise a swap approved at the very edge of the slack would + * begin playing past it and clip its own leading Costas array, reintroducing the + * exact defect this feature exists to avoid. + */ + static final int RESTART_HEADROOM_MS = 250; + + /** + * Whether the playback path these modes select can actually be interrupted mid-buffer. + * + *

    Only the sound-card paths can. {@code playViaUsbAudio} and {@code + * playViaAudioTrack} both poll the cancel flags {@link #requestTxRestart} sets and + * unwind within a chunk. The other two branches of {@code playFT8Signal} do not: + * + *

      + *
    • NETWORK hands the message to the ICOM network transmit callback and then + * spins {@code while (isTransmitting)} for up to 13.1 s; + *
    • CAT audio (controlMode CAT on a rig whose connector reports + * {@code supportTransmitOverCAT()}) does the same for up to 13.0 s. + *
    + * + *

    Neither observes {@code txAudioCancelled}, and {@link #requestTxRestart} + * deliberately leaves {@code isTransmitting} set, so a swap requested on those paths + * would not interrupt anything — it would sit queued until the wait expired and then + * replay ~13 s into the slot, where the clip math strips almost the whole message. + * That is worse than not swapping at all, so refuse up front and let the sequencer + * pick the change up next cycle as it did before. + * + * @param connectMode {@code GeneralVariables.connectMode} (see {@link ConnectMode}) + * @param controlMode {@code GeneralVariables.controlMode} (see {@link ControlMode}) + * @param catAudioInUse whether the connector actually transmits audio over CAT + */ + static boolean playbackSupportsMidCycleRestart(int connectMode, int controlMode, + boolean catAudioInUse) { + if (connectMode == ConnectMode.NETWORK) return false; + return !(controlMode == ControlMode.CAT && catAudioInUse); + } + + /** + * Whether an in-flight transmission should be aborted and restarted with a different + * message (the late-decode TX race). + * + *

    With early decode on, the fast pass delivers ~13.5 s into the slot and the + * auto-sequencer keys up ~0.45 s into the next one. The late full-slot pass + * (issue #363) routinely delivers its recovered decodes 0–3.5 s into that + * next slot — i.e. AFTER key-up. When one of those recovered decodes is the partner's + * reply, the sequencer advances the over a few hundred milliseconds too late and we + * spend the whole cycle re-sending the message we had already sent, instead of the + * RR73 that would have finished the QSO. + * + *

    The swap is free inside the audio slack. The waveform occupies + * {@code slotMillis - audioSlackMillis}, so a restart at any {@code msInCycle} within + * the slack still plays the new message COMPLETE and ends on the boundary — the + * receiver simply sees it at a slightly larger DT, which every FT8 decoder searches + * anyway. Past the slack the new message could not fit without clipping its leading + * sync array, so we let the original over finish rather than put a mutilated signal + * on the air; the sequencer picks the change up on the next cycle as it does today. + * + *

    PTT is deliberately NOT dropped for the swap — see {@link #requestTxRestart}. + * + * @param transmitting whether an over is currently on the air + * @param playbackRestartable whether this playback path can be interrupted at all — + * see {@link #playbackSupportsMidCycleRestart} + * @param transmitFreeText whether free text is armed — its content does not depend on + * {@code functionOrder}, so a swap would replay the identical + * message and put a discontinuity on the air for nothing + * @param orderAtKeyUp {@code functionOrder} captured when we keyed up + * @param orderNow {@code functionOrder} after this decode pass + * @param msInCycle ms elapsed since the current slot boundary (>= 0) + * @param audioSlackMillis {@link ModeProfile#audioSlackMillis} — free slack before clipping + */ + static boolean shouldRestartForNewOrder(boolean transmitting, boolean playbackRestartable, + boolean transmitFreeText, + int orderAtKeyUp, int orderNow, long msInCycle, + int audioSlackMillis) { + if (!transmitting) return false; + if (!playbackRestartable) return false; + if (transmitFreeText) return false; + // Nothing new to say: same over we already keyed up with. Also covers the + // "no reply decoded" case, where the sequencer leaves functionOrder alone. + if (orderNow == orderAtKeyUp) return false; + // orderAtKeyUp is only -1 before the first key-up of a run; without a known + // starting point we cannot tell a real change from initialization. + if (orderAtKeyUp == -1) return false; + if (msInCycle < 0) return false; + return msInCycle + RESTART_HEADROOM_MS <= audioSlackMillis; + } + /** * Build the replacement cycle timer for a new operating mode, carrying the manual * TX-delay offset ({@link UtcTimer#getTime_sec()}) from the outgoing timer onto the @@ -424,6 +528,10 @@ public void doTransmit() { // serial.send TX1/TX0 keying lines, so the QSO trace shows the message text. GeneralVariables.fileLog("QSO: TX slot=" + sequential + " order=" + functionOrder + " msg=[" + getFunctionCommand(functionOrder).getMessageText() + "]"); + // Baseline for the mid-cycle swap: anything that moves functionOrder off this + // value while we are on the air is a late decode we keyed up too early for. + orderAtKeyUp = functionOrder; + txRestartPending = false; doTransmitThreadPool.execute(doTransmitRunnable); mutableFunctions.postValue(snapshotForUi(functionList)); @@ -1105,10 +1213,97 @@ static String buildWriteAudioResultLog(boolean success) { + "no audio sent this cycle (rig keyed but silent)"; } + /** + * Abort the audio currently on the air and replay this same over with the message the + * sequencer has just chosen. Called from the decode thread when the late full-slot + * pass advances the QSO after key-up; see {@link #shouldRestartForNewOrder} for when + * that is safe. + * + *

    Uses the same cancel machinery as a STOP ({@code setTransmitting(false)}) to stop + * the writers mid-buffer, but deliberately leaves {@code isTransmitting} set and never + * calls {@code onAfterTransmit}: PTT stays keyed across the swap. + * Dropping and re-raising PTT would add the rig's full key-up delay (plus, on many + * rigs, a relay cycle) in the middle of a transmission — far more disruptive than the + * message change itself, and on some rigs enough to miss the slack window entirely. + * The TX worker's replay loop in {@link DoTransmitRunnable} picks it up from here. + */ + private void requestTxRestart() { + txRestartPending = true; + // Same two writers setTransmitting(false) stops: the native/fallback USB write + // and the chunked MODE_STREAM AudioTrack loop. + UsbAudioNative.cancelWrite(); + txAudioCancelled = true; + AudioTrack t = audioTrack; + if (t != null) { + try { + if (t.getState() != AudioTrack.STATE_UNINITIALIZED + && t.getPlayState() != AudioTrack.PLAYSTATE_STOPPED) { + t.pause(); + t.flush(); + } + } catch (IllegalStateException ignored) { + // Worker already released the track between our read and here. + } + } + } + + /** One-shot read of the restart request; clears it so each swap replays exactly once. */ + private boolean consumeTxRestart() { + if (!txRestartPending) return false; + txRestartPending = false; + return true; + } + + /** + * The message to put on the air right now: the armed free text if one is set, + * otherwise the standard message for the current {@code functionOrder}. Shared by the + * initial key-up and the mid-cycle swap replay so both resolve the over identically. + */ + private Ft8Message currentTransmitMessage() { + Ft8Message msg; + if (transmitFreeText) { + msg = new Ft8Message("CQ", GeneralVariables.myCallsign, freeText); + msg.i3 = 0; + msg.n3 = 0; + } else { + msg = getFunctionCommand(functionOrder); + } + msg.modifier = GeneralVariables.toModifier; + return msg; + } + + /** Push the on-air message text to the TX banner / mini-log. */ + @SuppressLint("DefaultLocale") + private void postTransmittingMessage(Ft8Message msg) { + mutableTransmittingMessage.postValue(String.format(" (%.0fHz) %s", + GeneralVariables.getBaseFrequency(), msg.getMessageText())); + } + /** * Actions after audio playback completes, including the onAfterTransmit callback for closing PTT. + * + *

    A pending mid-cycle message swap takes an early exit that releases only the audio + * resources: PTT stays keyed, {@code isTransmitting} stays true, and no + * {@code onAfterTransmit} fires, so the over continues seamlessly with the new message + * (see {@link #requestTxRestart}). Everything below the exit is genuine end-of-over + * teardown and must not run mid-swap. */ private void afterPlayAudio() { + // The swap exit is conditional on STILL TRANSMITTING. A STOP or deactivation + // (setTransmitting(false) / setActivated(false)) can land between the swap being + // queued and the writers unwinding; taking the early exit then would skip + // onAfterTransmit and leave the rig KEYED, and would leave txRestartPending set + // for a replay the operator just cancelled. Falling through instead drops the + // swap and runs the real end-of-over teardown, which is what a stop means. + if (txRestartPending && isTransmitting) { + if (audioTrack != null) { + audioTrack.release(); + audioTrack = null; + } + txAudioFocus.release(); + return; + } + txRestartPending = false; if (onDoTransmitted != null) { onDoTransmitted.onAfterTransmit(getFunctionCommand(functionOrder), functionOrder); } @@ -1638,6 +1833,30 @@ public void parseMessageToFunction(ArrayList msgList) { * @param evidenceOnly true for deep/late-pass decodes */ public void parseMessageToFunction(ArrayList msgList, boolean evidenceOnly) { + parseMessageToFunctionInner(msgList, evidenceOnly); + + // Mid-cycle message swap. Only the evidence-only (deep/late) pass can land after + // key-up — the fast pass delivers before the slot boundary, in time for the normal + // TX decision — so only it can leave us transmitting last over's message. Checked + // here, outside the body, so EVERY path that moves functionOrder above is covered + // (RR73 reply, completion, give-up) rather than just the ones we remembered to + // instrument. See shouldRestartForNewOrder for the safety window. + if (!evidenceOnly) return; + ModeProfile mode = GeneralVariables.currentMode(); + long msInCycle = UtcTimer.getSystemTime() % mode.slotMillis; + boolean playbackRestartable = playbackSupportsMidCycleRestart( + GeneralVariables.connectMode, GeneralVariables.controlMode, + onDoTransmitted != null && onDoTransmitted.supportTransmitOverCAT()); + if (shouldRestartForNewOrder(isTransmitting, playbackRestartable, transmitFreeText, + orderAtKeyUp, functionOrder, msInCycle, mode.audioSlackMillis)) { + GeneralVariables.fileLog("QSO: late decode advanced order " + orderAtKeyUp + + " -> " + functionOrder + " at " + msInCycle + + "ms into slot; restarting TX with the new message"); + requestTxRestart(); + } + } + + private void parseMessageToFunctionInner(ArrayList msgList, boolean evidenceOnly) { if (GeneralVariables.myCallsign.length() < 3) { return; } @@ -2949,15 +3168,7 @@ public void run() { } // for displaying the message content to be transmitted - Ft8Message msg; - if (transmitSignal.transmitFreeText) { - msg = new Ft8Message("CQ", GeneralVariables.myCallsign, transmitSignal.freeText); - msg.i3 = 0; - msg.n3 = 0; - } else { - msg = transmitSignal.getFunctionCommand(transmitSignal.functionOrder); - } - msg.modifier = GeneralVariables.toModifier; + Ft8Message msg = transmitSignal.currentTransmitMessage(); if (transmitSignal.onDoTransmitted != null) { // handle PTT and other events here @@ -2968,9 +3179,7 @@ public void run() { transmitSignal.mutableIsTransmitting.postValue(true); - transmitSignal.mutableTransmittingMessage.postValue(String.format(" (%.0fHz) %s" - , GeneralVariables.getBaseFrequency() - , msg.getMessageText())); + transmitSignal.postTransmittingMessage(msg); // generate signal // float[] buffer=GenerateFT8.generateFt8(msg, GeneralVariables.getBaseFrequency()); // if (buffer==null) { @@ -3003,24 +3212,45 @@ public void run() { // New behavior: skip only the excess past the slack. On-time and // mildly-late TXs send the full waveform; only genuinely late starts // begin clipping leading audio. - ModeProfile mode = GeneralVariables.currentMode(); - int msIntoCycle = (int) (UtcTimer.getSystemTime() % mode.slotMillis); - int msMaxStart = mode.audioSlackMillis; // last moment we can start without overrunning - int msLate = msIntoCycle - msMaxStart; - if (msLate < 0) msLate = 0; - if (msLate >= mode.slotMillis) msLate = mode.slotMillis - 1; - transmitSignal.lateStartSkipMs = msLate; - if (msLate > 100) { - Log.d(TAG, String.format("Late start: skipping %d ms of leading audio", msLate)); - ToastMessage.show(String.format("Late start: −%d ms", msLate)); - } + // Replay loop for the mid-cycle message swap (the late-decode TX race). The + // common case runs the body exactly once. When the late full-slot pass + // advances the QSO while this over is on the air, requestTxRestart() stops + // the audio and afterPlayAudio() skips its end-of-over teardown, so we come + // back here with PTT still keyed and send the newly chosen message instead. + // The clip math below is recomputed each pass, so the replay is timed against + // when IT starts, not when the original over did. + do { + ModeProfile mode = GeneralVariables.currentMode(); + int msIntoCycle = (int) (UtcTimer.getSystemTime() % mode.slotMillis); + int msMaxStart = mode.audioSlackMillis; // last moment we can start without overrunning + int msLate = msIntoCycle - msMaxStart; + if (msLate < 0) msLate = 0; + if (msLate >= mode.slotMillis) msLate = mode.slotMillis - 1; + transmitSignal.lateStartSkipMs = msLate; + if (msLate > 100) { + Log.d(TAG, String.format("Late start: skipping %d ms of leading audio", msLate)); + ToastMessage.show(String.format("Late start: −%d ms", msLate)); + } // if (transmitSignal.onDoTransmitted != null) {//process audio data for ICOM network mode transmission // transmitSignal.onDoTransmitted.onAfterGenerate(buffer); // } - // play audio - //transmitSignal.playFT8Signal(buffer); - transmitSignal.playFT8Signal(msg); + // play audio + //transmitSignal.playFT8Signal(buffer); + transmitSignal.playFT8Signal(msg); + + // isTransmitting re-checked alongside the flag: a STOP racing the swap + // request could otherwise replay an over the operator just cancelled. + if (!transmitSignal.consumeTxRestart() || !transmitSignal.isTransmitting) break; + // Swap: pick up whatever the sequencer settled on and replay this over. + msg = transmitSignal.currentTransmitMessage(); + transmitSignal.orderAtKeyUp = transmitSignal.functionOrder; + transmitSignal.postTransmittingMessage(msg); + GeneralVariables.fileLog("QSO: TX restart slot=" + transmitSignal.sequential + + " order=" + transmitSignal.functionOrder + + " at=" + (UtcTimer.getSystemTime() % GeneralVariables.currentMode().slotMillis) + + "ms msg=[" + msg.getMessageText() + "]"); + } while (true); } } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/LateDecodeTxRestartTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/LateDecodeTxRestartTest.java new file mode 100644 index 000000000..5b2e29e26 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/LateDecodeTxRestartTest.java @@ -0,0 +1,196 @@ +package com.k1af.ft8af.ft8transmit; + +import static com.google.common.truth.Truth.assertThat; + +import com.k1af.ft8af.connector.ConnectMode; +import com.k1af.ft8af.database.ControlMode; + +import org.junit.Test; + +/** + * Unit tests for {@link FT8TransmitSignal#shouldRestartForNewOrder} — the mid-cycle + * message swap that closes the late-decode TX race. + * + *

    The race, measured from a real POTA activation: the fast pass delivers ~13.5 s into + * the slot, the auto-sequencer keys up ~0.45 s into the next one, and the late full-slot + * pass (issue #363) then delivers its recovered decodes 0–3.5 s into that next slot. + * When one of those recovered decodes is the partner's reply, the sequencer advances the + * over just after key-up — in the logged case by 9 ms — and the whole cycle is spent + * re-sending the previous message instead of the RR73 that would have completed the QSO. + * + *

    The swap is free inside the audio slack (FT8: 2360 ms) because the replayed message + * still fits the slot complete; past it the new message could not fit without clipping its + * leading Costas sync array, which is the one thing that must never happen. + * + *

    Pure JVM: the predicate is static and touches no Android types. + */ +public class LateDecodeTxRestartTest { + + /** FT8: 15000 ms slot, 12640 ms of audio. */ + private static final int FT8_SLACK_MS = 2360; + /** FT4: 7500 ms slot, 5040 ms of audio. */ + private static final int FT4_SLACK_MS = 2460; + + // --------------------------------------------------------------- + // The case this exists for + // --------------------------------------------------------------- + + @Test + public void lateDecodeJustAfterKeyUp_restarts() { + // The logged 17:50 K5UUT case: keyed up at order 3 (R-10), the late pass + // advanced to order 2 about half a second into the slot. + assertThat(FT8TransmitSignal.shouldRestartForNewOrder( + true, true, false, 3, 2, 507, FT8_SLACK_MS)).isTrue(); + } + + @Test + public void lateDecodeAtTheVeryStartOfTheSlot_restarts() { + assertThat(FT8TransmitSignal.shouldRestartForNewOrder( + true, true, false, 3, 4, 0, FT8_SLACK_MS)).isTrue(); + } + + @Test + public void lateDecodeWellInsideTheSlack_restarts() { + assertThat(FT8TransmitSignal.shouldRestartForNewOrder( + true, true, false, 2, 3, 1500, FT8_SLACK_MS)).isTrue(); + } + + // --------------------------------------------------------------- + // The slack boundary — a restart past it would clip the new message + // --------------------------------------------------------------- + + @Test + public void restartExactlyAtTheHeadroomLimit_stillRestarts() { + int lastSafe = FT8_SLACK_MS - FT8TransmitSignal.RESTART_HEADROOM_MS; + assertThat(FT8TransmitSignal.shouldRestartForNewOrder( + true, true, false, 3, 4, lastSafe, FT8_SLACK_MS)).isTrue(); + } + + @Test + public void oneMsPastTheHeadroomLimit_doesNotRestart() { + int firstUnsafe = FT8_SLACK_MS - FT8TransmitSignal.RESTART_HEADROOM_MS + 1; + assertThat(FT8TransmitSignal.shouldRestartForNewOrder( + true, true, false, 3, 4, firstUnsafe, FT8_SLACK_MS)).isFalse(); + } + + @Test + public void restartInsideRawSlackButInsideHeadroom_doesNotRestart() { + // 2300 ms is within the 2360 ms slack, but the swap itself would push the + // replay past it — exactly the clipping this guard exists to prevent. + assertThat(FT8TransmitSignal.shouldRestartForNewOrder( + true, true, false, 3, 4, 2300, FT8_SLACK_MS)).isFalse(); + } + + @Test + public void lateDecodeWellPastTheSlack_doesNotRestart() { + // The 2.5-3.5 s arrivals: unsaveable, let the original over finish. + assertThat(FT8TransmitSignal.shouldRestartForNewOrder( + true, true, false, 3, 4, 3000, FT8_SLACK_MS)).isFalse(); + } + + @Test + public void ft4UsesItsOwnSlack() { + // FT4's slack is wider than FT8's, so a swap FT8 would refuse is fine here. + assertThat(FT8TransmitSignal.shouldRestartForNewOrder( + true, true, false, 3, 4, 2200, FT4_SLACK_MS)).isTrue(); + assertThat(FT8TransmitSignal.shouldRestartForNewOrder( + true, true, false, 3, 4, 2200, FT8_SLACK_MS)).isFalse(); + } + + // --------------------------------------------------------------- + // Cases that must never restart + // --------------------------------------------------------------- + + @Test + public void notTransmitting_doesNotRestart() { + // The ordinary case: the late pass advanced the sequencer between overs. + // Nothing is on the air, so the next key-up already sends the right message. + assertThat(FT8TransmitSignal.shouldRestartForNewOrder( + false, true, false, 3, 4, 500, FT8_SLACK_MS)).isFalse(); + } + + @Test + public void orderUnchanged_doesNotRestart() { + // The overwhelmingly common late-pass outcome: decodes arrive, none of them + // advance the QSO. Restarting here would swap the message for an identical + // one and put a needless discontinuity on the air. + assertThat(FT8TransmitSignal.shouldRestartForNewOrder( + true, true, false, 3, 3, 500, FT8_SLACK_MS)).isFalse(); + } + + @Test + public void noKeyUpBaseline_doesNotRestart() { + // Before the first key-up of a run there is no baseline to compare against, + // so a non-matching order is initialization, not a real change. + assertThat(FT8TransmitSignal.shouldRestartForNewOrder( + true, true, false, -1, 6, 500, FT8_SLACK_MS)).isFalse(); + } + + @Test + public void negativeMsInCycle_doesNotRestart() { + // A clock correction landing mid-over can produce this; refuse rather than + // reason about a slot position we don't trust. + assertThat(FT8TransmitSignal.shouldRestartForNewOrder( + true, true, false, 3, 4, -50, FT8_SLACK_MS)).isFalse(); + } + + @Test + public void freeTextArmed_doesNotRestart() { + // Free text does not depend on functionOrder, so the replay would send the + // identical message — a discontinuity on the air that buys nothing. Same + // inputs as lateDecodeJustAfterKeyUp_restarts, which does swap. + assertThat(FT8TransmitSignal.shouldRestartForNewOrder( + true, true, true, 3, 2, 507, FT8_SLACK_MS)).isFalse(); + } + + @Test + public void uninterruptiblePlaybackPath_doesNotRestart() { + // NETWORK and CAT-audio playback spin on isTransmitting for ~13 s and never + // observe the cancel flags, so a queued swap would not interrupt anything — it + // would fire ~13 s into the slot and the clip math would strip nearly the whole + // message. Same inputs as lateDecodeJustAfterKeyUp_restarts, which does swap. + assertThat(FT8TransmitSignal.shouldRestartForNewOrder( + true, false, false, 3, 2, 507, FT8_SLACK_MS)).isFalse(); + } + + // --------------------------------------------------------------- + // Which playback paths can be interrupted at all + // --------------------------------------------------------------- + + @Test + public void soundCardPathsAreRestartable() { + // USB-direct and AudioTrack both poll the cancel flags and unwind within a chunk. + assertThat(FT8TransmitSignal.playbackSupportsMidCycleRestart( + ConnectMode.USB_CABLE, ControlMode.VOX, false)).isTrue(); + assertThat(FT8TransmitSignal.playbackSupportsMidCycleRestart( + ConnectMode.BLUE_TOOTH, ControlMode.RTS, false)).isTrue(); + } + + @Test + public void networkPlaybackIsNotRestartable() { + assertThat(FT8TransmitSignal.playbackSupportsMidCycleRestart( + ConnectMode.NETWORK, ControlMode.VOX, false)).isFalse(); + // ...regardless of control mode. + assertThat(FT8TransmitSignal.playbackSupportsMidCycleRestart( + ConnectMode.NETWORK, ControlMode.CAT, true)).isFalse(); + } + + @Test + public void catAudioIsNotRestartableButCatControlAloneIs() { + // The CAT branch is only taken when the connector actually transmits audio over + // CAT (truSDX-style). A CAT-*controlled* rig whose audio goes out the sound card + // still uses the interruptible path and must keep the swap. + assertThat(FT8TransmitSignal.playbackSupportsMidCycleRestart( + ConnectMode.USB_CABLE, ControlMode.CAT, true)).isFalse(); + assertThat(FT8TransmitSignal.playbackSupportsMidCycleRestart( + ConnectMode.USB_CABLE, ControlMode.CAT, false)).isTrue(); + } + + @Test + public void headroomIsPositive() { + // A zero/negative headroom would let a swap start at the slack boundary and + // clip its own leading sync array by the time playback actually began. + assertThat(FT8TransmitSignal.RESTART_HEADROOM_MS).isGreaterThan(0); + assertThat(FT8TransmitSignal.RESTART_HEADROOM_MS).isLessThan(FT8_SLACK_MS); + } +} From 58f9c57ea2092e5dcb3849b48afb4a977cfc52fa Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 31 Jul 2026 10:22:07 -0500 Subject: [PATCH 095/113] Bound GPS clock discipline to corrections FT8 can survive, and log them (#705) * Bound GPS clock discipline to corrections FT8 can survive, and log them GpsClockUpdater.applyFix() writes UtcTimer.delay from every GPS fix, on by default at a 5-minute cadence. That value moves the WHOLE cycle grid -- every decode window and every transmit key-up -- and its only guard was an absolute +/-1 hour bound. Two changes: - MAX_SANE_OFFSET_MS: 1 hour -> 60 s. A phone even a few seconds out cannot work FT8, so an hour-scale "correction" can only be a mock provider, a bogus fix, or a timezone confusion. 60 s still covers a genuinely unsynced clock; past that the operator has a clock to fix. - New step bound (MAX_OFFSET_STEP_MS, 500 ms): reject a fix that jumps the applied offset by more than half a second within a discipline run. The absolute bound cannot catch the failure that actually bites -- a single bad fix whose implied correction looks plausible but slides the grid off the air. Physics makes it cheap to detect: GPS time does not jump and a device clock drifts milliseconds between fixes minutes apart, so a multi-second STEP is bad data whatever its absolute value. Only a run's first fix is unconstrained, since that is the one legitimately correcting accumulated drift. Motivating data (2026-07-30 POTA activation, from debug.log): the app spent two stretches transmitting 5.06 s and 7.63 s off grid. Every over in them keyed up past the 2.36 s audio slack, so lateStartSkipMs clipped the leading Costas sync array out of 17 of 126 transmissions (13.5%) -- loud on the air, undecodable at the far end. Both offsets sit well inside the old absolute bound; only a step check refuses them. That GPS discipline caused those two stretches remains INFERRED, not proven: applyFix only ever logged to logcat, so the pulled debug.log could not show it. Hence the second half of this change -- every applied offset and every rejection now goes to debug.log with the prior offset and the bound that refused it, so the next activation settles it either way. Co-Authored-By: Claude Opus 5 (1M context) * Address Copilot review: sample both clocks once in applyFix() SystemClock.elapsedRealtimeNanos() and System.currentTimeMillis() were each read twice -- once for the offset that gets logged, once for the offset that gets evaluated against the bounds -- so the logged "REJECTED fix offset=" was not guaranteed to be the number actually judged. That undermines the diagnostics this PR exists to add, and is most likely to diverge in exactly the situation being diagnosed: a clock being corrected underneath us. Both clocks are now sampled once and the same readings feed the evaluation, the log, and the last-sync timestamp posted to the UI. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../k1af/ft8af/location/GpsClockUpdater.java | 100 ++++++++++++++++-- .../ft8af/location/GpsClockUpdaterTest.java | 88 ++++++++++++++- 2 files changed, 174 insertions(+), 14 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/location/GpsClockUpdater.java b/ft8af/app/src/main/java/com/k1af/ft8af/location/GpsClockUpdater.java index d348a747d..1c821a44f 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/location/GpsClockUpdater.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/location/GpsClockUpdater.java @@ -48,11 +48,35 @@ public class GpsClockUpdater extends LocationSubscriber { * A fix implying a correction larger than this is treated as bad data and ignored * (see {@link #isOffsetSane}). Fix age is not checked separately: {@link #gpsUtcNow} * folds it into the implied offset, so a stale fix shows up here as a large offset. - * GPS UTC and the device clock are never legitimately an hour apart; a value that - * big means a mock provider, a bogus fix, or a timezone confusion, none of which - * should be allowed to yank the transmit timing. + * + *

    This bound was ±1 hour, which is far looser than anything FT8 can survive: the + * offset it admits is written straight into {@link UtcTimer#delay}, which moves the + * WHOLE cycle grid — every decode window and every transmit key-up. A phone that is + * even a few seconds out cannot work FT8 at all, so an hour-scale "correction" can + * only ever be a mock provider, a bogus fix, or a timezone confusion. 60 s still + * covers a genuinely unsynced clock (no network for days) while refusing the + * nonsense; past it the operator has a clock problem to fix, not one to paper over. */ - static final long MAX_SANE_OFFSET_MS = 60L * 60L * 1000L; + static final long MAX_SANE_OFFSET_MS = 60L * 1000L; + + /** + * Largest jump allowed between consecutive GPS-applied offsets within one session. + * + *

    The absolute bound above cannot catch the failure that actually bites: a single + * bad fix whose implied correction is small enough to look plausible but large enough + * to slide the FT8 grid off the air. On the 2026-07-30 activation the app spent two + * stretches transmitting 5.06 s and 7.63 s off grid — every over in them keyed up + * past the 2.36 s audio slack, so {@code lateStartSkipMs} clipped the leading Costas + * sync array out of 13.5% of that session's transmissions: loud on the air, invisible + * to receivers. + * + *

    Physics makes this cheap to detect. GPS time does not jump, and a device clock + * drifts on the order of milliseconds between fixes minutes apart. A multi-second + * STEP is therefore bad data by definition, whatever its absolute value. Only the + * first fix of a session gets to move the clock freely (there is no baseline to + * compare against, and that fix is the one legitimately correcting real drift). + */ + static final long MAX_OFFSET_STEP_MS = 500L; /** Configurable update-interval bounds (minutes), per issue #373. */ static final int MIN_INTERVAL_MINUTES = 1; @@ -75,6 +99,12 @@ public class GpsClockUpdater extends LocationSubscriber { // manualTimeCorrectionMs, which NTP never writes. NO_SAVED_DELAY means "not disciplining". private int savedDelayBeforeGps = NO_SAVED_DELAY; + // Baseline for the step bound (see MAX_OFFSET_STEP_MS): the last offset GPS actually + // applied, and whether there has been one at all this discipline run. Reset by stop() + // so a fresh run's first fix is again free to correct real accumulated drift. + private volatile boolean hasAppliedOffset = false; + private volatile int lastAppliedOffsetMs = 0; + private GpsClockUpdater(Context context) { super(context); } @@ -194,6 +224,11 @@ protected synchronized void stop() { // Return UtcTimer.delay to whatever it was before GPS took over (an NTP sync, a manual // correction, or 0) rather than to manualTimeCorrectionMs — NTP writes delay but never // manualTimeCorrectionMs, so restoring the latter would silently discard the NTP sync. + // A new discipline run starts with no baseline, so its first fix may again correct + // whatever drift accumulated while GPS was off. + hasAppliedOffset = false; + lastAppliedOffsetMs = 0; + if (savedDelayBeforeGps != NO_SAVED_DELAY) { UtcTimer.delay = savedDelayBeforeGps; Log.d(TAG, "Stopped GPS clock discipline; restored pre-GPS offset " @@ -209,24 +244,51 @@ private void applyFix(Location location) { // computeAppliedOffset also re-checks running, so a fix that was already queued on the // main looper when the user disabled discipline (stop() flipped running=false) is // dropped instead of re-writing the clock after we've handed it back. + // Sample both clocks EXACTLY once and feed the same readings to the evaluation and + // to the log. Reading them twice let the logged "REJECTED fix offset=" disagree + // with the number actually judged against the bounds — which would undermine the + // diagnostics this logging exists to provide, and is most likely to bite in the + // very situation being diagnosed (a clock being corrected underneath us). + final long fixElapsedNanos = location.getElapsedRealtimeNanos(); + final long nowElapsedNanos = SystemClock.elapsedRealtimeNanos(); + final long nowSystemMs = System.currentTimeMillis(); + int rawOffsetMs = gpsClockOffsetMs(fixUtcMs, fixElapsedNanos, nowElapsedNanos, nowSystemMs); Integer offsetMs = computeAppliedOffset( isRunning(), fixUtcMs, - location.getElapsedRealtimeNanos(), - SystemClock.elapsedRealtimeNanos(), - System.currentTimeMillis()); + fixElapsedNanos, + nowElapsedNanos, + nowSystemMs, + hasAppliedOffset, + lastAppliedOffsetMs); if (offsetMs == null) { Log.d(TAG, "Ignoring GPS fix (not running or implausible): fixUtc=" + fixUtcMs); + // debug.log, not just logcat: this path moves the whole FT8 cycle grid, so a + // rejected fix has to be visible in the same log the on-air symptoms are. + // (The old code logged only to logcat, which is why the 2026-07-30 activation + // could not be diagnosed from the pulled debug.log.) + if (isRunning()) { + GeneralVariables.fileLog("GpsClock: REJECTED fix offset=" + rawOffsetMs + + "ms (prior=" + (hasAppliedOffset ? lastAppliedOffsetMs + "ms" : "none") + + ", absMax=" + MAX_SANE_OFFSET_MS + "ms, maxStep=" + MAX_OFFSET_STEP_MS + + "ms) — clock left at " + UtcTimer.delay + "ms"); + } return; } + GeneralVariables.fileLog("GpsClock: applied offset " + offsetMs + "ms (was " + + UtcTimer.delay + "ms, prior GPS=" + + (hasAppliedOffset ? lastAppliedOffsetMs + "ms" : "none") + ")"); + lastAppliedOffsetMs = offsetMs; + hasAppliedOffset = true; UtcTimer.delay = offsetMs; GeneralVariables.gpsClockOffsetMs = offsetMs; // The UI renders this as "... UTC", so post the disciplined time, not the raw // (possibly-wrong) system clock we just computed a correction for. - GeneralVariables.mutableGpsClockSync.postValue( - disciplinedUtcMs(System.currentTimeMillis(), offsetMs)); + // Same single sampling as the evaluation above, so the displayed last-sync instant + // is the one this offset was actually computed against. + GeneralVariables.mutableGpsClockSync.postValue(disciplinedUtcMs(nowSystemMs, offsetMs)); Log.d(TAG, "GPS clock discipline applied offset " + offsetMs + "ms"); } @@ -268,6 +330,22 @@ static boolean isOffsetSane(long fixUtcMs, int offsetMs) { return Math.abs((long) offsetMs) <= MAX_SANE_OFFSET_MS; } + /** + * Whether {@code offsetMs} is a believable successor to the offset already applied + * this session. See {@link #MAX_OFFSET_STEP_MS} for why a step bound catches what the + * absolute bound cannot. + * + * @param hasPriorOffset whether GPS has already disciplined the clock this session; + * false for the first fix, which is unconstrained by this check + * @param priorOffsetMs the offset currently applied (meaningless when no prior) + * @param offsetMs the offset this fix implies + */ + static boolean isOffsetStepSane(boolean hasPriorOffset, int priorOffsetMs, int offsetMs) { + if (!hasPriorOffset) return true; + long step = Math.abs((long) offsetMs - (long) priorOffsetMs); + return step <= MAX_OFFSET_STEP_MS; + } + /** Coerce a configured update interval into the allowed range (minutes). */ public static int clampIntervalMinutes(int minutes) { if (minutes < MIN_INTERVAL_MINUTES) return MIN_INTERVAL_MINUTES; @@ -314,10 +392,12 @@ static boolean shouldResubscribe(boolean running, long subscribedIntervalMs, lon * without a {@link LocationManager}. */ static Integer computeAppliedOffset(boolean running, long fixUtcMs, long fixElapsedRealtimeNanos, - long nowElapsedRealtimeNanos, long nowSystemMs) { + long nowElapsedRealtimeNanos, long nowSystemMs, + boolean hasPriorOffset, int priorOffsetMs) { if (!running) return null; int offsetMs = gpsClockOffsetMs(fixUtcMs, fixElapsedRealtimeNanos, nowElapsedRealtimeNanos, nowSystemMs); if (!isOffsetSane(fixUtcMs, offsetMs)) return null; + if (!isOffsetStepSane(hasPriorOffset, priorOffsetMs, offsetMs)) return null; return offsetMs; } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/location/GpsClockUpdaterTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/location/GpsClockUpdaterTest.java index bb9d0e6cf..f00a73583 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/location/GpsClockUpdaterTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/location/GpsClockUpdaterTest.java @@ -84,7 +84,7 @@ public void sane_rejectsZeroFixTime() { @Test public void sane_rejectsAbsurdOffset() { - // Beyond an hour: mock provider / timezone confusion / bogus fix. + // Beyond the absolute bound: mock provider / timezone confusion / bogus fix. int tooBig = (int) (GpsClockUpdater.MAX_SANE_OFFSET_MS + 1); assertThat(GpsClockUpdater.isOffsetSane(1_700_000_000_000L, tooBig)).isFalse(); assertThat(GpsClockUpdater.isOffsetSane(1_700_000_000_000L, -tooBig)).isFalse(); @@ -96,6 +96,86 @@ public void sane_acceptsExactlyAtBound() { assertThat(GpsClockUpdater.isOffsetSane(1_700_000_000_000L, atBound)).isTrue(); } + @Test + public void sane_absoluteBoundIsTightEnoughForFt8() { + // Guards the tightening itself. The old ±1 h bound let an hour-scale "correction" + // be written straight into UtcTimer.delay, which moves every decode window and + // every transmit key-up. Anything near that is unusable for FT8 by definition. + assertThat(GpsClockUpdater.MAX_SANE_OFFSET_MS).isAtMost(60_000L); + // ...but still generous enough to fix a genuinely unsynced clock. + assertThat(GpsClockUpdater.MAX_SANE_OFFSET_MS).isAtLeast(30_000L); + } + + // ---- isOffsetStepSane (the bound that catches a plausible-looking bad fix) ---- + + @Test + public void step_firstFixIsUnconstrained() { + // No baseline yet, and this is the fix that legitimately corrects accumulated + // drift — it must be allowed to move the clock by a lot. + assertThat(GpsClockUpdater.isOffsetStepSane(false, 0, 45_000)).isTrue(); + assertThat(GpsClockUpdater.isOffsetStepSane(false, 0, -45_000)).isTrue(); + } + + @Test + public void step_rejectsTheMultiSecondJumpThatWentOnAir() { + // The 2026-07-30 activation: the grid slid 5.06 s and 7.63 s, and every over in + // those stretches keyed up past the audio slack with its leading Costas array + // clipped. Both are within the absolute bound, so only the step check stops them. + assertThat(GpsClockUpdater.isOffsetStepSane(true, 0, 5_060)).isFalse(); + assertThat(GpsClockUpdater.isOffsetStepSane(true, 0, 7_630)).isFalse(); + // ...and each is still "sane" by the absolute bound alone, which is the point. + assertThat(GpsClockUpdater.isOffsetSane(1_700_000_000_000L, 5_060)).isTrue(); + } + + @Test + public void step_acceptsOrdinaryDriftBetweenFixes() { + // A device clock drifts milliseconds between fixes minutes apart. + assertThat(GpsClockUpdater.isOffsetStepSane(true, 120, 145)).isTrue(); + assertThat(GpsClockUpdater.isOffsetStepSane(true, -80, -60)).isTrue(); + } + + @Test + public void step_boundaryIsInclusive() { + int prior = 100; + int atBound = prior + (int) GpsClockUpdater.MAX_OFFSET_STEP_MS; + assertThat(GpsClockUpdater.isOffsetStepSane(true, prior, atBound)).isTrue(); + assertThat(GpsClockUpdater.isOffsetStepSane(true, prior, atBound + 1)).isFalse(); + } + + @Test + public void step_measuredAgainstPriorNotZero() { + // A large but STABLE offset must keep being accepted: once the first fix has + // corrected a badly-unsynced clock, later fixes near that value are correct and + // must not be rejected for being far from zero. + assertThat(GpsClockUpdater.isOffsetStepSane(true, 40_000, 40_050)).isTrue(); + } + + @Test + public void step_rejectsJumpBackToZeroFromLargeOffset() { + // The mirror image: having disciplined to +40 s, a fix implying ~0 is a bad fix, + // not the clock spontaneously fixing itself. + assertThat(GpsClockUpdater.isOffsetStepSane(true, 40_000, 0)).isFalse(); + } + + // ---- computeAppliedOffset with the step bound wired in ---- + + @Test + public void appliedOffset_nullWhenStepTooLarge() { + // Running, absolute-sane, but a 3 s jump from the applied offset: dropped. + Integer r = GpsClockUpdater.computeAppliedOffset( + true, 11_000L, 5000L * MS, 5000L * MS, 8_000L, true, 0); + assertThat(r).isNull(); + } + + @Test + public void appliedOffset_allowsSmallStepFromPrior() { + // Same fix geometry as appliedOffset_returnsOffsetWhenRunningAndSane (+2000), + // with a prior offset close enough that the step passes. + Integer r = GpsClockUpdater.computeAppliedOffset( + true, 10_000L, 5000L * MS, 5000L * MS, 8_000L, true, 1_800); + assertThat(r).isEqualTo(2_000); + } + // ---- clampIntervalMinutes ---- @Test @@ -156,21 +236,21 @@ public void resubscribe_trueWhenCadenceChanges() { @Test public void appliedOffset_nullWhenNotRunning() { // A fix that raced past a disable (running flipped false) must not touch the clock. - Integer r = GpsClockUpdater.computeAppliedOffset(false, 10_000L, 5000L * MS, 5000L * MS, 8_000L); + Integer r = GpsClockUpdater.computeAppliedOffset(false, 10_000L, 5000L * MS, 5000L * MS, 8_000L, false, 0); assertThat(r).isNull(); } @Test public void appliedOffset_nullWhenInsane() { // fixUtc==0 (no time in the fix) is rejected even while running. - Integer r = GpsClockUpdater.computeAppliedOffset(true, 0L, 5000L * MS, 5000L * MS, 8_000L); + Integer r = GpsClockUpdater.computeAppliedOffset(true, 0L, 5000L * MS, 5000L * MS, 8_000L, false, 0); assertThat(r).isNull(); } @Test public void appliedOffset_returnsOffsetWhenRunningAndSane() { // GPS UTC now = 10_000 (fresh fix), device reads 8_000 => +2_000. - Integer r = GpsClockUpdater.computeAppliedOffset(true, 10_000L, 5000L * MS, 5000L * MS, 8_000L); + Integer r = GpsClockUpdater.computeAppliedOffset(true, 10_000L, 5000L * MS, 5000L * MS, 8_000L, false, 0); assertThat(r).isEqualTo(2_000); } From 366695a2b6eb5228fd0e2fbc796ae0b9935b100a Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 31 Jul 2026 10:22:55 -0500 Subject: [PATCH 096/113] Stop setOperationBand re-sending an unchanged dial to the rig ~1x/second (#706) * Stop setOperationBand re-sending an unchanged dial to the rig ~1x/second From the 2026-07-30 POTA activation: setOperationBand() ran in a continuous ~1 Hz loop for the entire session, re-sending FA014074000; MD0C; NA00;SH0117; about 57 times a minute to a rig that was already on that exact frequency and mode -- rig.getFreq matched the target on every iteration. 20,124 occurrences across the pulled log, present in every POTA session in it, at the same rate during completely healthy stretches. New RetunePolicy makes the retune idempotent: a request is pushed only when it is a new dial, or the rig is not where we want it, or a 30 s reassert heartbeat is due. Ordering matters and is what the tests pin -- correctness beats the rate limit, so a genuine retune is never delayed and the operator can never be left transmitting on the old dial. Only a request redundant in BOTH senses (same target as the last push AND the rig already reporting it) is throttled. This is CONTAINMENT, not a root-cause fix, and the caller driving the loop is still unidentified. It is provably not the connect path (11 autoConnect attempts in the whole window, no connect/disconnect churn logged), not a band change (no bandSelect: lines), and not self-triggering via onFreqChanged (BaseRig.setFreq early-returns on an unchanged dial, and the dial never changed). Two independent ~1.05 s series interleave ~0.53 s apart, one always observing the rig connected and one always observing it disconnected -- which points at duplicated observers or two live view-model instances rather than one runaway timer, but that is inference. So the change also adds a rate-limited suppression log that names the caller via its stack frame: setOperationBand: suppressed 28 redundant retunes (freq=14074000 already set) caller=com.k1af.ft8af.Xyz.tick:42 The stack is only walked on the rate-limited log path, never per suppressed call. The next activation's debug.log should name the culprit so the real fix can follow. Co-Authored-By: Claude Opus 5 (1M context) * Address Copilot review: reset the rate limit on connect, harden the clocks 1. The rate-limit state persisted across a reconnect. onConnected() posts setOperationBand() precisely because a reconnect "previously left the rig on whatever frequency it powered up on" -- but that push has the same dial as the last one and a cached baseRig.getFreq() that still matches, so a reconnect inside the 30s reassert window would have been suppressed and silently regressed the bug that retune was added to fix. onConnected() now calls resetRetuneRateLimit() so the push is treated as a first push. Deliberately NOT reset from setOperationBand()'s not-connected branch: in the ~1 Hz loop this rate limit exists to contain, half the calls observe the rig disconnected, so resetting there would re-arm the loop every other iteration and defeat the fix entirely. 2. shouldLogSuppression() relied on a 0 sentinel being far enough below an epoch nowMs to clear the interval by arithmetic, which would have delayed the first line if it were ever fed a monotonic clock. There is now an explicit NEVER_LOGGED sentinel. Both intervals are measured with System.currentTimeMillis(), which is not monotonic. A backwards OS time correction made the raw delta negative and wedged the caller -- retunes suppressed, or the suppression log silenced, until wall time caught up. elapsedSince() saturates on backwards time so both fail safe (one extra CAT write, one extra log line) instead of silently disabling themselves. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../java/com/k1af/ft8af/MainViewModel.java | 57 +++++ .../com/k1af/ft8af/rigs/RetunePolicy.java | 132 ++++++++++++ .../com/k1af/ft8af/rigs/RetunePolicyTest.java | 198 ++++++++++++++++++ 3 files changed, 387 insertions(+) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/rigs/RetunePolicy.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/rigs/RetunePolicyTest.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 0a41a1376..4d949bcfe 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -90,6 +90,7 @@ import com.k1af.ft8af.rigs.BaseRig; import com.k1af.ft8af.rigs.BaseRigOperation; import com.k1af.ft8af.rigs.CatConnectionState; +import com.k1af.ft8af.rigs.RetunePolicy; import com.k1af.ft8af.rigs.CatLiveness; import com.k1af.ft8af.rigs.DiscoveryTX500Rig; import com.k1af.ft8af.rigs.ElecraftRig; @@ -288,6 +289,18 @@ public void onConnected() { //connected to rig setCatConnectionState(CatConnectionState.CONNECTED); ToastMessage.show(getStringFromResource(R.string.connected_rig)); + // A new link is a new session for the retune rate limit, so the push below is + // treated as a first push and can never be throttled. Without this the + // reconnect case the comment below describes would silently regress: the + // requested dial still equals the last one we pushed and baseRig's cached + // freq still matches it, so a reconnect inside the reassert window would be + // suppressed and the rig would keep whatever it powered up on. + // + // Deliberately NOT reset from setOperationBand()'s not-connected branch: in + // the ~1 Hz loop this rate limit exists to contain, half the calls observe + // the rig disconnected, so resetting there would re-arm the loop every other + // iteration and defeat the fix entirely. + resetRetuneRateLimit(); // Push the app's current band/frequency to the rig on every connect — // including an automatic reconnect, which previously left the rig on // whatever frequency it powered up on ("no frequency set after connecting"). @@ -1515,8 +1528,31 @@ public boolean tryStartTuneViaAtu() { return false; } + // Rate-limit state for setOperationBand(). See RetunePolicy for why this exists and + // what is still unexplained about the caller. + private long lastPushedBandFreq = RetunePolicy.NO_PUSH; + private long lastBandPushAtMs = 0L; + private long lastRetuneSuppressionLogAtMs = RetunePolicy.NEVER_LOGGED; + private int suppressedRetunes = 0; + + /** + * Forget what we last pushed, so the next {@code setOperationBand()} is treated as a + * first push and goes out unthrottled. Called on every successful connect. + */ + private void resetRetuneRateLimit() { + lastPushedBandFreq = RetunePolicy.NO_PUSH; + lastBandPushAtMs = 0L; + lastRetuneSuppressionLogAtMs = RetunePolicy.NEVER_LOGGED; + suppressedRetunes = 0; + } + /** * Set the operating carrier frequency. Only operates if the rig is connected. + * + *

    Redundant requests — same dial as the last push, rig already reporting it — are + * suppressed down to a slow reassert heartbeat by {@link RetunePolicy}. A genuine + * retune (new dial, or a rig that has moved) is never delayed. This is containment for + * a ~1 Hz caller that has not been identified; the suppression log below names it. */ public void setOperationBand() { if (!isRigConnected()) { @@ -1524,6 +1560,27 @@ public void setOperationBand() { return; } + long nowMs = System.currentTimeMillis(); + if (!RetunePolicy.shouldRetune(GeneralVariables.band, baseRig.getFreq(), + lastPushedBandFreq, nowMs, lastBandPushAtMs)) { + suppressedRetunes++; + if (RetunePolicy.shouldLogSuppression(nowMs, lastRetuneSuppressionLogAtMs)) { + // Name the caller: the ~1 Hz driver of this loop is not identifiable from + // the source, so record who is actually asking. Only on the rate-limited + // path — building a stack trace per suppressed call would be its own leak. + fileLog("setOperationBand: suppressed " + suppressedRetunes + + " redundant retunes (freq=" + GeneralVariables.band + + " already set) caller=" + RetunePolicy.callerOf( + Thread.currentThread().getStackTrace(), + MainViewModel.class.getName())); + lastRetuneSuppressionLogAtMs = nowMs; + suppressedRetunes = 0; + } + return; + } + lastPushedBandFreq = GeneralVariables.band; + lastBandPushAtMs = nowMs; + fileLog("setOperationBand: sending USB mode, then freq=" + GeneralVariables.band + " in 800ms (controlMode=" + GeneralVariables.controlMode + ")"); //set USB mode first, then set frequency diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/RetunePolicy.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/RetunePolicy.java new file mode 100644 index 000000000..9b3819f94 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/RetunePolicy.java @@ -0,0 +1,132 @@ +package com.k1af.ft8af.rigs; + +/** + * Rate limiting for {@code MainViewModel.setOperationBand()} — the CAT retune that pushes + * the app's dial and USB mode to the rig. + * + *

    Motivating data (2026-07-30 POTA activation, from {@code debug.log}): {@code + * setOperationBand()} ran in a continuous ~1 Hz loop for the ENTIRE session, re-sending + * {@code FA014074000; MD0C; NA00;SH0117;} about 57 times a minute to a rig that was + * already on that exact frequency and mode — {@code rig.getFreq} matched the target on + * every single iteration. 20,124 occurrences across the pulled log, present in every POTA + * session in it. + * + *

    This is containment, not a root-cause fix. The caller driving that + * loop has not been identified. It is provably not the connect path (only 11 autoConnect + * attempts in the whole window, and no connect/disconnect churn logged), not a band change + * (no {@code bandSelect:} lines), and not self-triggering through {@code onFreqChanged} + * ({@code BaseRig.setFreq} early-returns on an unchanged dial, and the dial never changed). + * Two independent ~1.05 s series interleave about 0.53 s apart, one always observing the + * rig connected and one always observing it disconnected, which suggests duplicated + * observers or two live view-model instances rather than one runaway timer. + * + *

    So this class does two things: makes the retune idempotent so the spam stops whatever + * the caller turns out to be, and gives {@code setOperationBand()} a rate-limited + * suppression log that names the caller — so the next activation's {@code debug.log} + * identifies the culprit instead of leaving it to inference. + */ +public final class RetunePolicy { + + /** + * How often an unchanged dial is re-asserted to the rig anyway. A push that changes + * nothing is not useless — a rig can be moved at the front panel, or drop a command — + * so we keep a slow heartbeat rather than going fully edge-triggered. 30 s is two + * orders of magnitude below the observed 1 Hz loop and far above any legitimate + * retune cadence. + */ + public static final long REASSERT_INTERVAL_MS = 30_000L; + + /** Minimum gap between "suppressed N retunes" log lines, so the fix can't itself spam. */ + public static final long SUPPRESSION_LOG_INTERVAL_MS = 10_000L; + + /** {@link #shouldRetune} sentinel for "nothing pushed yet this session". */ + public static final long NO_PUSH = 0L; + + /** {@link #shouldLogSuppression} sentinel for "no suppression logged yet". */ + public static final long NEVER_LOGGED = Long.MIN_VALUE; + + private RetunePolicy() {} + + /** + * Milliseconds from {@code thenMs} to {@code nowMs}, treating a clock that has moved + * BACKWARDS as "the interval has elapsed". + * + *

    Both intervals here are measured with {@link System#currentTimeMillis()}, which is + * not monotonic — an OS time correction can move it backwards under us, and this app + * runs on devices whose clocks are actively disciplined. A raw subtraction would then + * go negative and wedge the caller: retunes suppressed, or the suppression log silenced, + * until wall time caught back up. Saturating at "elapsed" fails safe in both cases (one + * extra CAT write, one extra log line) instead of silently disabling them. + */ + static long elapsedSince(long nowMs, long thenMs) { + long delta = nowMs - thenMs; + return delta < 0 ? Long.MAX_VALUE : delta; + } + + /** + * Whether this retune request should actually reach the rig. + * + *

    Ordered so correctness always beats the rate limit: a genuinely new target, or a + * rig that is not where we want it, is pushed immediately and unconditionally. Only a + * request that is redundant in BOTH senses — same target as last time, and the rig + * already reports being there — is subject to the reassert interval. + * + * @param requestedFreq the dial we want the rig on ({@code GeneralVariables.band}) + * @param rigFreq what the rig last reported ({@code baseRig.getFreq()}) + * @param lastPushedFreq the dial we last actually pushed, or {@link #NO_PUSH} + * @param nowMs current wall clock + * @param lastPushAtMs when we last actually pushed (meaningless if {@link #NO_PUSH}) + */ + public static boolean shouldRetune(long requestedFreq, long rigFreq, long lastPushedFreq, + long nowMs, long lastPushAtMs) { + // Never throttle the first push of a session: on connect the rig may still be on + // whatever frequency it powered up on, and its cached freq may coincidentally + // match ours without the mode ever having been sent. + if (lastPushedFreq == NO_PUSH) return true; + // A real band/dial change must go out at once — this is the whole point of the + // call, and delaying it would leave the operator transmitting on the old dial. + if (requestedFreq != lastPushedFreq) return true; + // The rig disagrees with us (front-panel move, dropped command): correct it now. + if (rigFreq != requestedFreq) return true; + // Fully redundant. Re-assert only on the slow heartbeat. + return elapsedSince(nowMs, lastPushAtMs) >= REASSERT_INTERVAL_MS; + } + + /** + * Whether enough time has passed to emit another suppression summary. + * + *

    {@link #NEVER_LOGGED} is handled explicitly rather than relying on a sentinel of 0 + * being far enough below an epoch {@code nowMs} to clear the interval by arithmetic — + * that coupling would silently delay the first line if this were ever fed a monotonic + * clock. + */ + public static boolean shouldLogSuppression(long nowMs, long lastLogAtMs) { + if (lastLogAtMs == NEVER_LOGGED) return true; + return elapsedSince(nowMs, lastLogAtMs) >= SUPPRESSION_LOG_INTERVAL_MS; + } + + /** + * First stack frame outside {@code selfClassName} — i.e. whoever called the method + * doing the logging. Returns {@code "unknown"} rather than throwing on a stack that + * doesn't contain one (possible under aggressive inlining or a synthetic frame). + * + *

    Exists so the suppression log can name the runaway caller. Only ever invoked on + * the rate-limited log path, never per suppressed call. + */ + public static String callerOf(StackTraceElement[] stack, String selfClassName) { + if (stack == null || selfClassName == null) return "unknown"; + boolean seenSelf = false; + for (StackTraceElement frame : stack) { + String cls = frame.getClassName(); + if (cls == null) continue; + // Skip the Thread.getStackTrace()/Throwable frames that precede the caller. + if (cls.equals(selfClassName)) { + seenSelf = true; + continue; + } + if (!seenSelf) continue; + return cls + "." + frame.getMethodName() + ":" + frame.getLineNumber(); + } + return "unknown"; + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/RetunePolicyTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/RetunePolicyTest.java new file mode 100644 index 000000000..6e169d219 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/RetunePolicyTest.java @@ -0,0 +1,198 @@ +package com.k1af.ft8af.rigs; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Unit tests for {@link RetunePolicy} — the rate limit that stops {@code + * MainViewModel.setOperationBand()} re-sending an unchanged dial to the rig about once a + * second, all session (20,124 occurrences in the pulled 2026-07-30 log). + * + *

    The load-bearing property is the ordering: correctness beats the rate limit. Every + * "must not be throttled" case below is a way the operator could otherwise end up + * transmitting on the wrong dial. + */ +public class RetunePolicyTest { + + private static final long FREQ = 14_074_000L; + private static final long OTHER_FREQ = 7_074_000L; + private static final long T0 = 1_700_000_000_000L; + + // --------------------------------------------------------------- + // Must never be throttled + // --------------------------------------------------------------- + + @Test + public void firstPushOfSessionAlwaysGoesOut() { + // On connect the rig may still be on its power-up frequency, and its cached freq + // can match ours without the USB mode ever having been sent. + assertThat(RetunePolicy.shouldRetune( + FREQ, FREQ, RetunePolicy.NO_PUSH, T0, 0L)).isTrue(); + } + + @Test + public void newDialGoesOutImmediately() { + // A real band change. Delaying this leaves the operator on the old dial. + assertThat(RetunePolicy.shouldRetune( + OTHER_FREQ, FREQ, FREQ, T0 + 10, T0)).isTrue(); + } + + @Test + public void rigOnAWrongFrequencyIsCorrectedImmediately() { + // Front-panel move, or the rig dropped our command. Same target as last push, + // but the rig is not there — push regardless of how recently we pushed. + assertThat(RetunePolicy.shouldRetune( + FREQ, OTHER_FREQ, FREQ, T0 + 10, T0)).isTrue(); + } + + @Test + public void reconnectIsNotThrottled_whenStateIsResetOnConnect() { + // MainViewModel.onConnected() calls resetRetuneRateLimit(), which restores + // NO_PUSH. That is what keeps the connect-time retune working: without it, a + // reconnect inside the reassert window has the same dial as the last push AND a + // cached rig freq that matches, so it would be suppressed and the rig would keep + // whatever frequency it powered up on — the exact bug that retune was added for. + assertThat(RetunePolicy.shouldRetune( + FREQ, FREQ, FREQ, T0 + 1_000, T0)).isFalse(); // without the reset + assertThat(RetunePolicy.shouldRetune( + FREQ, FREQ, RetunePolicy.NO_PUSH, T0 + 1_000, T0)).isTrue(); // with it + } + + @Test + public void newDialWinsEvenWhenRigAlreadyReportsIt() { + // The rig can report the new dial before we have pushed the mode for it. + assertThat(RetunePolicy.shouldRetune( + OTHER_FREQ, OTHER_FREQ, FREQ, T0 + 10, T0)).isTrue(); + } + + // --------------------------------------------------------------- + // The loop this exists to kill + // --------------------------------------------------------------- + + @Test + public void redundantRepeatIsSuppressed() { + // The observed loop: same dial, rig already there, ~1 s after the last push. + assertThat(RetunePolicy.shouldRetune( + FREQ, FREQ, FREQ, T0 + 1_050, T0)).isFalse(); + } + + @Test + public void aFullSecondOfLoopIterationsIsAllSuppressed() { + // 57 calls/min was the measured rate; none of them should reach the rig. + for (long dt = 100; dt < RetunePolicy.REASSERT_INTERVAL_MS; dt += 1_050) { + assertThat(RetunePolicy.shouldRetune(FREQ, FREQ, FREQ, T0 + dt, T0)).isFalse(); + } + } + + // --------------------------------------------------------------- + // The slow reassert heartbeat + // --------------------------------------------------------------- + + @Test + public void redundantRepeatIsReassertedAfterTheInterval() { + assertThat(RetunePolicy.shouldRetune( + FREQ, FREQ, FREQ, T0 + RetunePolicy.REASSERT_INTERVAL_MS, T0)).isTrue(); + } + + @Test + public void justBeforeTheIntervalIsStillSuppressed() { + assertThat(RetunePolicy.shouldRetune( + FREQ, FREQ, FREQ, T0 + RetunePolicy.REASSERT_INTERVAL_MS - 1, T0)).isFalse(); + } + + @Test + public void reassertIntervalIsFarBelowTheObservedLoopRate() { + // Guards the constant: the loop ran at ~1.05 s, so anything in that neighbourhood + // would fail to contain it. + assertThat(RetunePolicy.REASSERT_INTERVAL_MS).isAtLeast(10_000L); + } + + // --------------------------------------------------------------- + // Suppression logging + // --------------------------------------------------------------- + + @Test + public void suppressionLogIsRateLimited() { + assertThat(RetunePolicy.shouldLogSuppression(T0, T0)).isFalse(); + assertThat(RetunePolicy.shouldLogSuppression( + T0 + RetunePolicy.SUPPRESSION_LOG_INTERVAL_MS - 1, T0)).isFalse(); + assertThat(RetunePolicy.shouldLogSuppression( + T0 + RetunePolicy.SUPPRESSION_LOG_INTERVAL_MS, T0)).isTrue(); + } + + @Test + public void firstSuppressionLogsImmediately() { + // NEVER_LOGGED is an explicit sentinel, not a 0 that happens to be far enough + // below an epoch timestamp to clear the interval by arithmetic. + assertThat(RetunePolicy.shouldLogSuppression(T0, RetunePolicy.NEVER_LOGGED)).isTrue(); + // ...and it holds for a small (monotonic-style) clock too, which the old + // arithmetic-only form would have got wrong. + assertThat(RetunePolicy.shouldLogSuppression(5L, RetunePolicy.NEVER_LOGGED)).isTrue(); + } + + // --------------------------------------------------------------- + // Non-monotonic wall clock (System.currentTimeMillis can move backwards) + // --------------------------------------------------------------- + + @Test + public void clockMovingBackwardsDoesNotWedgeTheReassertHeartbeat() { + // An OS time correction lands between the push and the next call. A raw + // subtraction would go negative and suppress every retune until wall time caught + // back up; failing safe means one extra CAT write instead. + assertThat(RetunePolicy.shouldRetune(FREQ, FREQ, FREQ, T0 - 60_000, T0)).isTrue(); + } + + @Test + public void clockMovingBackwardsDoesNotSilenceTheSuppressionLog() { + assertThat(RetunePolicy.shouldLogSuppression(T0 - 60_000, T0)).isTrue(); + } + + @Test + public void elapsedSinceSaturatesOnBackwardsTime() { + assertThat(RetunePolicy.elapsedSince(T0 + 500, T0)).isEqualTo(500L); + assertThat(RetunePolicy.elapsedSince(T0, T0)).isEqualTo(0L); + assertThat(RetunePolicy.elapsedSince(T0 - 1, T0)).isEqualTo(Long.MAX_VALUE); + } + + // --------------------------------------------------------------- + // Caller identification (the diagnostic half) + // --------------------------------------------------------------- + + @Test + public void callerOf_returnsFirstFrameAfterSelf() { + StackTraceElement[] stack = { + new StackTraceElement("java.lang.Thread", "getStackTrace", "Thread.java", 1), + new StackTraceElement("com.k1af.ft8af.MainViewModel", "setOperationBand", "MainViewModel.java", 1521), + new StackTraceElement("com.k1af.ft8af.Mystery", "tick", "Mystery.java", 42), + new StackTraceElement("java.util.TimerThread", "run", "Timer.java", 1), + }; + assertThat(RetunePolicy.callerOf(stack, "com.k1af.ft8af.MainViewModel")) + .isEqualTo("com.k1af.ft8af.Mystery.tick:42"); + } + + @Test + public void callerOf_skipsConsecutiveSelfFrames() { + // setOperationBand called from another MainViewModel method: report the first + // frame OUTSIDE the class, not the internal hop. + StackTraceElement[] stack = { + new StackTraceElement("java.lang.Thread", "getStackTrace", "Thread.java", 1), + new StackTraceElement("com.k1af.ft8af.MainViewModel", "setOperationBand", "MainViewModel.java", 1521), + new StackTraceElement("com.k1af.ft8af.MainViewModel", "lambda$onConnected$0", "MainViewModel.java", 297), + new StackTraceElement("android.os.Handler", "handleCallback", "Handler.java", 1), + }; + assertThat(RetunePolicy.callerOf(stack, "com.k1af.ft8af.MainViewModel")) + .isEqualTo("android.os.Handler.handleCallback:1"); + } + + @Test + public void callerOf_handlesMissingSelfAndNulls() { + StackTraceElement[] noSelf = { + new StackTraceElement("java.lang.Thread", "getStackTrace", "Thread.java", 1), + }; + assertThat(RetunePolicy.callerOf(noSelf, "com.k1af.ft8af.MainViewModel")).isEqualTo("unknown"); + assertThat(RetunePolicy.callerOf(null, "com.k1af.ft8af.MainViewModel")).isEqualTo("unknown"); + assertThat(RetunePolicy.callerOf(noSelf, null)).isEqualTo("unknown"); + assertThat(RetunePolicy.callerOf(new StackTraceElement[0], "x")).isEqualTo("unknown"); + } +} From 9e126bf3d6a850962e49b48492c1b0f90ee91772 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 31 Jul 2026 10:23:18 -0500 Subject: [PATCH 097/113] Keep the QSO panel's RX history instead of re-deriving it each recomposition (#707) * Keep the QSO panel's RX history instead of re-deriving it each recomposition Reported: "sometimes the little QSO details pane would lose the rx messages I had received and it would resize smaller." The panel OWNED its TX rows -- synthTxLog, a remembered state list -- but DERIVED its RX/BUSY rows by filtering the shared decode list on every recomposition. That list is not stable storage: - trimToMessageCount() drops from the FRONT once it reaches MESSAGE_COUNT (3000). At the ~50 kept decodes/min seen on the 2026-07-30 activation that fills in about an hour, mid-session, with no user action. - the clear-decodes-every-cycle setting wipes it at each slot boundary. - clearDecodesAndTarget() empties it on a band change, and the Clear button empties it outright. Any of those retroactively erased conversation the operator had already read, while the TX rows stayed -- exactly the reported asymmetry. And MessageLog's LazyColumn is heightIn(max = 160.dp) with no minimum, so it sizes to its content: fewer rows literally shrank the box, and zero rows swapped in a fixed 40.dp placeholder. That is the resize. RX rows now accumulate per target into their own remembered list, folded forward by mergeRxLog() and reset on a genuine target change alongside synthTxLog. Duplicates are expected input rather than an error: the decode list is cumulative and the late full-slot pass re-delivers a slot's messages, so identity is (direction, utcTime, messageText) -- time included so a station repeating itself in a later cycle still gets its own row, matching how TX rows are logged per transmission. Growth is bounded at MAX_RX_LOG_ENTRIES newest. Also fixes the second path to the same symptom. buildQsoLog returns an empty list when displayCallsign is empty, and that value comes from a LiveData observeAsState the code already documents as briefly emitting null on tab switches (the #250 comment). #250 fixed this for the TX rows with a 500ms settle on synthTxTarget but left the conversation keyed on the raw value. The log now targets displayCallsign ?: synthTxTarget, so a flicker can no longer blank it; a real QSO end still clears both, one target change later, exactly as before. mergeRxLog returns the caller's own instance when it adds nothing, and the composable skips the snapshot write on referential equality -- so a cumulative snapshot that contributes no new rows costs no recomposition. Size alone would have been wrong: at the cap a merge can append and trim in one step, leaving the size identical and the content different. Co-Authored-By: Claude Opus 5 (1M context) * Address Copilot review: let a known row's metadata be updated, not discarded mergeRxLog keyed on (direction, utcTime, messageText) and kept the FIRST instance of a key, silently dropping metadata carried by later duplicates. That is reachable, and by a sharper route than "the same message might arrive twice": FT8SignalListener.checkMessageSame mutates the STORED Ft8Message in place -- "prefer known SNR over unknown; when both are known, keep the higher" -- and then drops the duplicate. So ft8Messages holds one instance whose snr field improves over time, typically when the late full-slot pass re-decodes a message the fast pass only heard weakly. Keying snr out of identity meant the panel pinned whatever SNR it saw first, often none, for the rest of the QSO. Before this PR the panel re-derived from the live list each recomposition and picked the improvement up immediately, so this was a regression introduced by the accumulation. A repeat of a known key now replaces the stored row when it differs structurally, which mirrors upstream's own resolution (it only ever moves toward the better value). An identical repeat -- the common case, every cycle -- still short-circuits and returns the caller's own instance, so the no-recomposition contract is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../ft8af/ui/components/ActiveQsoPanel.kt | 117 ++++++++- .../ui/components/QsoPanelRxHistoryTest.kt | 231 ++++++++++++++++++ 2 files changed, 342 insertions(+), 6 deletions(-) create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/QsoPanelRxHistoryTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ActiveQsoPanel.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ActiveQsoPanel.kt index ec816256f..e1591058a 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ActiveQsoPanel.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/ActiveQsoPanel.kt @@ -124,6 +124,76 @@ internal fun buildQsoLog( return entries.sortedBy { it.utcTime } } +/** + * Upper bound on the accumulated RX log for a single target, so a QSO that never + * completes (a stuck target on a busy frequency) can't grow the list without limit. + * Far above any real exchange — a standard QSO is 4-6 rows, and a stubborn one with + * repeats is a couple of dozen. + */ +internal const val MAX_RX_LOG_ENTRIES = 100 + +/** + * Stable identity for one conversation row, used to de-duplicate across snapshots. + * + *

    Time is part of the key on purpose: a station that sends the same text in two + * different cycles produced two real transmissions and gets two rows, matching how TX + * rows are logged per transmission rather than per unique message. + */ +internal fun qsoLogKey(entry: QsoLogEntry): String = + "${entry.direction}|${entry.utcTime}|${entry.messageText}" + +/** + * Merge freshly-decoded rows into the rows this panel has already shown. + * + *

    This is what makes the panel's RX history durable. The decode list it is derived + * from is shared, capped, and periodically emptied — `trimToMessageCount` drops from the + * front once it hits `MESSAGE_COUNT` (about an hour of busy-band operating), the + * clear-every-cycle setting wipes it at each slot boundary, a band change or the Clear + * button empties it outright. Re-deriving the conversation from it on every recomposition + * meant any of those retroactively erased messages the operator had already read, and the + * log box — which sizes to its content — visibly shrank. TX rows never had this problem + * because they live in the composable's own state; now RX rows don't either. + * + *

    Duplicates are expected input, not an error: the decode list is cumulative, so the + * same message reappears in every later snapshot, and the late full-slot pass re-delivers + * a slot's messages alongside newly recovered ones. + * + *

    A repeat of a known key REPLACES the stored row rather than being discarded, because + * a row's metadata is not fixed once seen. `FT8SignalListener.checkMessageSame` upgrades a + * stored message's SNR *in place* when a later pass decodes the same text better ("prefer + * known SNR over unknown; when both are known, keep the higher"), so the same key can + * legitimately arrive with a better SNR than the one first captured. Keeping the first + * would pin the panel to a stale — often unknown — report for the rest of the QSO. + * Latest-wins matches that upstream resolution, which only ever moves toward the better + * value. + * + * @param existing rows already accumulated for the current target + * @param incoming rows just derived from the live decode list + * @return the union, ascending by time, trimmed to [MAX_RX_LOG_ENTRIES] newest + */ +internal fun mergeRxLog( + existing: List, + incoming: List, +): List { + if (incoming.isEmpty()) return existing + val byKey = LinkedHashMap(existing.size + incoming.size) + existing.forEach { byKey[qsoLogKey(it)] = it } + var changed = false + incoming.forEach { entry -> + val key = qsoLogKey(entry) + // Structural inequality, so an unchanged repeat (the common case, every cycle) + // still costs nothing and preserves the caller's instance below. + if (byKey[key] != entry) { + byKey[key] = entry + changed = true + } + } + if (!changed) return existing + val merged = byKey.values.sortedBy { it.utcTime } + if (merged.size <= MAX_RX_LOG_ENTRIES) return merged + return merged.subList(merged.size - MAX_RX_LOG_ENTRIES, merged.size).toList() +} + /** * Outcome of one evaluation of the synthesized-TX-log effect. * @@ -224,6 +294,13 @@ fun ActiveQsoPanel( var pendingTxLog by remember { mutableStateOf(false) } var synthTxTarget by remember { mutableStateOf(null) } + // Accumulated RX/BUSY rows for the current target, held here for the same reason + // synthTxLog is: the panel must not lose conversation it has already shown. These + // used to be re-derived from the shared decode list on every recomposition, so a + // trim, a clear-every-cycle wipe, a band change, or the Clear button silently + // erased them mid-QSO and the log box shrank. See mergeRxLog. + val rxLog = remember { mutableStateListOf() } + // Clear TX state when the target genuinely changes to a different station. // Also handles the same-callsign-new-QSO edge case: when a QSO ends // (displayCallsign → null for > 500ms) and the same station is worked @@ -235,6 +312,7 @@ fun ActiveQsoPanel( if (displayCallsign != null) { if (displayCallsign != synthTxTarget) { synthTxLog.clear() + rxLog.clear() wasTransmitting = false pendingTxLog = false } @@ -271,17 +349,44 @@ fun ActiveQsoPanel( } } - // Build the conversation log to/from the target station. The classification - // and ordering live in buildQsoLog() so they can be unit tested. - val qsoMessages: List = remember(messageList, messageList?.size, displayCallsign, transmittingMessage, synthTxLog.size) { - buildQsoLog( + // The target the conversation log belongs to. Falls back to synthTxTarget — which + // LaunchedEffect(displayCallsign) only clears after a 500ms settle — so the same + // LiveData null-flicker on tab switches that #250 fixed for the TX rows can no + // longer blank the RX rows either. A genuine QSO end still clears both, one target + // change later, exactly as before. + val logTarget = displayCallsign ?: synthTxTarget + + // Fold this snapshot's decodes for the target into the durable RX log. Only new + // rows survive the merge, so the cumulative decode list re-presenting the same + // messages every cycle is a no-op rather than a source of duplicates. + LaunchedEffect(messageList, messageList?.size, logTarget) { + val target = logTarget ?: return@LaunchedEffect + val incoming = buildQsoLog( messageList = messageList, - displayCallsign = displayCallsign ?: "", + displayCallsign = target, myCallsign = GeneralVariables.myCallsign ?: "", - synthTx = synthTxLog.toList(), + synthTx = emptyList(), ) + val existing = rxLog.toList() + val merged = mergeRxLog(existing, incoming) + // mergeRxLog returns the caller's OWN instance when it added nothing, so this + // identity check keeps snapshot writes (and the recomposition each triggers) to + // real changes. Size alone would miss a merge that both added and trimmed at + // MAX_RX_LOG_ENTRIES. + if (merged !== existing) { + rxLog.clear() + rxLog.addAll(merged) + } } + // The conversation shown: durable RX/BUSY rows plus our own TX rows, in time order. + // Computed inline rather than remember()d — these are snapshot state lists, so + // reading them here registers the dependency and any change recomposes. A remember + // keyed on sizes would miss a merge that trims as it appends. + val qsoMessages: List = + if (logTarget == null) emptyList() + else (rxLog + synthTxLog).sortedBy { it.utcTime } + AnimatedVisibility( visible = expanded && (hasTarget || isActivated), enter = expandVertically(expandFrom = Alignment.Bottom), diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/QsoPanelRxHistoryTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/QsoPanelRxHistoryTest.kt new file mode 100644 index 000000000..96c8f2a4c --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/QsoPanelRxHistoryTest.kt @@ -0,0 +1,231 @@ +package radio.ks3ckc.ft8af.ui.components + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [mergeRxLog] / [qsoLogKey] — the accumulation that makes the QSO panel's + * RX history durable. + * + * Reported symptom: "sometimes the little QSO details pane would lose the RX messages I + * had received and it would resize smaller." + * + * Cause: the panel owned its TX rows (a remembered state list) but *derived* its RX rows + * from the shared decode list on every recomposition. That list is capped + * (`trimToMessageCount` drops from the front at `MESSAGE_COUNT`, roughly an hour of + * busy-band operating), wiped by the clear-every-cycle setting, and emptied outright by a + * band change or the Clear button — so any of those retroactively erased conversation the + * operator had already read, and the log box, which sizes to its content, visibly shrank. + * + * These tests pin the merge behaviour that fixes it. Pure JVM: no Android types. + */ +class QsoPanelRxHistoryTest { + + private fun rx(time: Long, text: String) = + QsoLogEntry(QsoLogEntry.Direction.RX, time, text) + + private fun busy(time: Long, text: String) = + QsoLogEntry(QsoLogEntry.Direction.BUSY, time, text) + + // --------------------------------------------------------------- + // The bug: history must survive the shared list losing entries + // --------------------------------------------------------------- + + @Test + fun historySurvivesTheDecodeListBeingCleared() { + // Three exchanges accumulated, then the shared decode list is wiped + // (clear-every-cycle, band change, or the Clear button) so the next snapshot + // derives nothing. The panel must still show what it already showed. + var log = mergeRxLog(emptyList(), listOf(rx(1_000, "K1AF RA3XYZ -12"))) + log = mergeRxLog(log, listOf(rx(2_000, "K1AF RA3XYZ R-09"))) + log = mergeRxLog(log, listOf(rx(3_000, "K1AF RA3XYZ RR73"))) + assertThat(log).hasSize(3) + + val afterClear = mergeRxLog(log, emptyList()) + + assertThat(afterClear).hasSize(3) + assertThat(afterClear.map { it.messageText }) + .containsExactly("K1AF RA3XYZ -12", "K1AF RA3XYZ R-09", "K1AF RA3XYZ RR73") + .inOrder() + } + + @Test + fun historySurvivesTheOldestEntriesBeingTrimmedAway() { + // trimToMessageCount drops from the FRONT of the shared list, so a later + // snapshot can be missing the QSO's earliest messages. They must not vanish + // from the panel. + val full = mergeRxLog(emptyList(), listOf(rx(1_000, "first"), rx(2_000, "second"))) + val trimmedSnapshot = listOf(rx(2_000, "second")) + + val merged = mergeRxLog(full, trimmedSnapshot) + + assertThat(merged.map { it.messageText }).containsExactly("first", "second").inOrder() + } + + // --------------------------------------------------------------- + // Duplicates are expected input, not an error + // --------------------------------------------------------------- + + @Test + fun cumulativeSnapshotsDoNotDuplicateRows() { + // The decode list is cumulative, so every later snapshot re-presents the same + // messages. Re-merging one must be a no-op. + val first = mergeRxLog(emptyList(), listOf(rx(1_000, "K1AF RA3XYZ -12"))) + val again = mergeRxLog(first, listOf(rx(1_000, "K1AF RA3XYZ -12"))) + + assertThat(again).hasSize(1) + } + + @Test + fun lateFullSlotPassRedeliveryDoesNotDuplicate() { + // The late pass re-delivers a slot's messages alongside newly recovered ones: + // the known row must not double, the new one must land. + val afterFast = mergeRxLog(emptyList(), listOf(rx(1_000, "K1AF RA3XYZ -12"))) + val afterLate = mergeRxLog( + afterFast, + listOf(rx(1_000, "K1AF RA3XYZ -12"), rx(1_000, "W9ABC RA3XYZ -05")), + ) + + assertThat(afterLate).hasSize(2) + } + + @Test + fun sameTextInDifferentCyclesGetsItsOwnRow() { + // Two real transmissions, e.g. an unanswered report repeated next cycle. + // Mirrors how TX rows are logged per transmission rather than per unique text. + val log = mergeRxLog( + emptyList(), + listOf(rx(1_000, "K1AF RA3XYZ RR73"), rx(16_000, "K1AF RA3XYZ RR73")), + ) + + assertThat(log).hasSize(2) + } + + @Test + fun directionIsPartOfIdentity() { + // Same text and time but a different classification is a different row. + val log = mergeRxLog(emptyList(), listOf(rx(1_000, "CQ RA3XYZ"), busy(1_000, "CQ RA3XYZ"))) + + assertThat(log).hasSize(2) + assertThat(qsoLogKey(rx(1_000, "x"))).isNotEqualTo(qsoLogKey(busy(1_000, "x"))) + } + + // --------------------------------------------------------------- + // Identity contract the composable's write-skip depends on + // --------------------------------------------------------------- + + // --------------------------------------------------------------- + // Metadata on a known row can still improve + // --------------------------------------------------------------- + + @Test + fun aBetterSnrForAKnownRowReplacesIt() { + // FT8SignalListener.checkMessageSame upgrades a stored message's SNR in place + // when a later pass decodes the same text better ("prefer known SNR over + // unknown; when both are known, keep the higher"), so the same key legitimately + // arrives again carrying a better report. Discarding it would pin the panel to + // the first — often unknown — value for the rest of the QSO. + val first = mergeRxLog( + emptyList(), + listOf(QsoLogEntry(QsoLogEntry.Direction.RX, 1_000, "K1AF RA3XYZ -12", null)), + ) + val updated = mergeRxLog( + first, + listOf(QsoLogEntry(QsoLogEntry.Direction.RX, 1_000, "K1AF RA3XYZ -12", -9)), + ) + + assertThat(updated).hasSize(1) + assertThat(updated.single().snr).isEqualTo(-9) + assertThat(updated).isNotSameInstanceAs(first) + } + + @Test + fun anUnchangedRepeatDoesNotCountAsAnUpdate() { + // The common case every cycle: identical row, including SNR. Must stay a no-op + // so it costs no recomposition. + val entry = QsoLogEntry(QsoLogEntry.Direction.RX, 1_000, "K1AF RA3XYZ -12", -9) + val first = mergeRxLog(emptyList(), listOf(entry)) + + assertThat(mergeRxLog(first, listOf(entry))).isSameInstanceAs(first) + } + + @Test + fun updatingARowDoesNotDisturbOrderOrCount() { + var log = mergeRxLog( + emptyList(), + listOf(rx(1_000, "first"), rx(2_000, "second"), rx(3_000, "third")), + ) + log = mergeRxLog( + log, + listOf(QsoLogEntry(QsoLogEntry.Direction.RX, 2_000, "second", -3)), + ) + + assertThat(log.map { it.messageText }).containsExactly("first", "second", "third").inOrder() + assertThat(log[1].snr).isEqualTo(-3) + } + + @Test + fun mergeReturnsTheSameInstanceWhenNothingIsAdded() { + // ActiveQsoPanel skips the snapshot write (and the recomposition it triggers) + // on referential equality, so this is load-bearing, not an optimisation detail. + val existing = mergeRxLog(emptyList(), listOf(rx(1_000, "a"))) + + assertThat(mergeRxLog(existing, emptyList())).isSameInstanceAs(existing) + assertThat(mergeRxLog(existing, listOf(rx(1_000, "a")))).isSameInstanceAs(existing) + } + + @Test + fun mergeReturnsANewInstanceWhenSomethingIsAdded() { + val existing = mergeRxLog(emptyList(), listOf(rx(1_000, "a"))) + + assertThat(mergeRxLog(existing, listOf(rx(2_000, "b")))).isNotSameInstanceAs(existing) + } + + // --------------------------------------------------------------- + // Ordering and the growth bound + // --------------------------------------------------------------- + + @Test + fun mergedRowsAreOrderedByTime() { + // Out-of-order arrival is normal: the late pass recovers a message from earlier + // in the slot after the fast pass has already delivered a later one. + val log = mergeRxLog( + mergeRxLog(emptyList(), listOf(rx(3_000, "third"))), + listOf(rx(1_000, "first"), rx(2_000, "second")), + ) + + assertThat(log.map { it.messageText }).containsExactly("first", "second", "third").inOrder() + } + + @Test + fun growthIsBoundedKeepingTheNewestRows() { + // A target that never completes must not grow the log without limit. + var log: List = emptyList() + for (i in 1..MAX_RX_LOG_ENTRIES + 20) { + log = mergeRxLog(log, listOf(rx(i.toLong() * 1_000, "msg$i"))) + } + + assertThat(log).hasSize(MAX_RX_LOG_ENTRIES) + // Oldest dropped, newest kept — the recent exchange is what matters on screen. + assertThat(log.first().messageText).isEqualTo("msg21") + assertThat(log.last().messageText).isEqualTo("msg${MAX_RX_LOG_ENTRIES + 20}") + } + + @Test + fun trimAndAppendInOneMergeStillReturnsANewInstance() { + // The case a size-only change check would miss: at the cap, a merge that appends + // one row and drops one row leaves the size identical but the content different. + var log: List = emptyList() + for (i in 1..MAX_RX_LOG_ENTRIES) { + log = mergeRxLog(log, listOf(rx(i.toLong() * 1_000, "msg$i"))) + } + assertThat(log).hasSize(MAX_RX_LOG_ENTRIES) + + val merged = mergeRxLog(log, listOf(rx(999_000, "newest"))) + + assertThat(merged).hasSize(MAX_RX_LOG_ENTRIES) + assertThat(merged).isNotSameInstanceAs(log) + assertThat(merged.last().messageText).isEqualTo("newest") + assertThat(merged.first().messageText).isEqualTo("msg2") + } +} From 5328833fc0499bb2e40c5af015ad803eb6096a5c Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 31 Jul 2026 19:39:17 -0500 Subject: [PATCH 098/113] Remove the GPS step bound and debounce the retune reset (both made things worse) (#708) * Remove the GPS step bound and debounce the retune reset (both made things worse) Both regressions caught on the 2026-07-31 activation by the diagnostics #705 and #706 added, which is the one thing that went right. 1. Remove MAX_OFFSET_STEP_MS (#705). The reasoning -- GPS time does not jump, so a multi-second step is bad data -- is sound in isolation and wrong as a rule, because it cannot tell "this fix is bad" from "the baseline is bad". Paired with an unconstrained first fix it made the first fix of a run permanent: 16:11:31 applied offset -1331ms (was -5000ms, prior GPS=none) 16:23:53 REJECTED offset -2112ms (prior=-1331ms, maxStep=500ms) ... eleven consecutive rejections over an hour, all near -2200ms ... A cold fix 2.6s after startup set -1331ms and every later fix was refused for being ~870ms away from it. Cost, measured across the 17:23 boundary where GPS finally got through: decodes 5.9/cycle before against 8.7 after, and transmit timing scattered (14 of 110 overs keying up 5s+ into the slot, versus 21 of 23 tight afterwards). One outlier is noise; eleven agreeing fixes are the truth, and the rule could never act on that. Rejecting a correction is not the safe default it looks like -- a stale offset puts the grid off the air just as surely as a bad fix, and unlike a bad fix it never self-corrects. The absolute bound and the logging stay. 2. Debounce the retune rate-limit reset (#706). The suppression log named the runaway caller directly: caller=com.k1af.ft8af.MainViewModel$1$$ExternalSyntheticLambda0.run:0 which is the MainViewModel.this::setOperationBand posted from onConnected(). CableSerialPort fires that on every successful port open(), and the port was re-opening about once a second -- so the reset added in code review re-armed the limiter on every iteration of the loop it exists to contain. Every suppression line read "suppressed 1" and retunes rose to 73/min, above the 57/min measured before the rate limit existed. Resetting is still right for a genuinely new link, so it is now debounced: a burst of connects seconds apart is one flapping link and does not re-arm; a reconnect after a real outage does. This also corrects the record on #706: I ruled out the connect path on 7/30 because there were few autoConnect attempts, but onConnected() fires per port open() without a fresh autoConnect, so that proxy was meaningless. The port re-open storm is the real root cause and is still to be fixed. Co-Authored-By: Claude Opus 5 (1M context) * Stop the CAT reconnect storm: a port that opens is not a link that works Root cause of the ~1 Hz retune loop, the constant "serial.send: port not open!", and the "PTT: unkey still owed after link loss" retries. Two compounding defects in CableConnector: 1. handleSerialError() passed a hardcoded 0 as attemptsSoFar to CatReconnectPolicy.decide(). Since shouldAutoReconnect was `attemptsSoFar < MAX`, a transient error ALWAYS returned RECONNECT and the SURFACE branch was unreachable for anything non-fatal. The parameter exists to bound this and was never fed. 2. startAutoReconnect() treated cableSerialPort.connect() returning true -- the port merely OPENING -- as success, returned, and ended the burst. With a link that opened and immediately errored again, the next error started a FRESH burst at attempt 1, so the escalation (500ms/1s/2s/4s/8s) never got past its first step. Measured on the 2026-07-31 activation: 13,190 port opens in 88 minutes (2.5/s), inter-arrival pinned at 0.51-0.53s -- exactly BASE_BACKOFF_MS plus the open -- and zero "Lost connection" lines. The give-up path never fired once in 88 minutes of continuous reconnecting, which is what proves the budget was being reset by every open. The fix is that only elapsed time proves a link works. The burst counter now persists across opens and resets only after a connection has held for STABLE_CONNECTION_MS, so a flapping link walks up to the 8s ceiling in a few attempts instead of sitting at 500ms forever: ~20x less churn (2.5/s -> 0.125/s). Retry is now unbounded for transient errors, per operator preference. Giving up would strand them with no CAT until they noticed the retry chip, whereas the storm was at least landing commands intermittently. FATAL classifications (device gone, permission denied) still surface immediately -- those don't recover by retrying. This does NOT explain why the port dies right after opening. That is a genuine link fault -- cable, OTG adapter, RFI, driver -- and this change only stops the software turning it into a 2 Hz storm. Expect it to make the underlying instability more visible, not less. Co-Authored-By: Claude Opus 5 (1M context) * Address Copilot review: atomic burst counter, and rename the stale constant 1. reconnectAttempt was a volatile int mutated with `++` on the CAT-Auto-Reconnect thread while handleSerialError() (serial IO thread) and connect() (UI thread) reset it. `++` is a read-modify-write and is not atomic under volatile, so a lost update would hold the counter down -- pinning the backoff near BASE_BACKOFF_MS and reviving the exact storm this PR exists to stop. Now an AtomicInteger, and handleSerialError() resets-and-snapshots as one logical step so the value handed to decide() is the one that call established. 2. MAX_AUTO_RECONNECT_ATTEMPTS no longer bounded anything -- transient errors retry indefinitely -- so the name was actively misleading. Renamed to BACKOFF_ESCALATION_ATTEMPTS, which is what it describes: the attempt at which backoff reaches MAX_BACKOFF_MS. A new test pins that relationship so the name cannot drift from the behaviour again. The rename surfaced three more docs carrying the same dead "budget" framing -- the class javadoc, the Action.RECONNECT/SURFACE constants, and decide()'s contract -- all corrected. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../java/com/k1af/ft8af/MainViewModel.java | 29 +++--- .../k1af/ft8af/connector/CableConnector.java | 63 ++++++++++--- .../ft8af/connector/CatReconnectPolicy.java | 78 +++++++++++++--- .../k1af/ft8af/location/GpsClockUpdater.java | 66 +++++--------- .../com/k1af/ft8af/rigs/RetunePolicy.java | 37 ++++++++ .../connector/CatReconnectPolicyTest.java | 76 ++++++++++++++-- .../ft8af/location/GpsClockUpdaterTest.java | 91 +++++-------------- .../com/k1af/ft8af/rigs/RetunePolicyTest.java | 38 ++++++++ 8 files changed, 324 insertions(+), 154 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 4d949bcfe..a8563fcfb 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -289,18 +289,23 @@ public void onConnected() { //connected to rig setCatConnectionState(CatConnectionState.CONNECTED); ToastMessage.show(getStringFromResource(R.string.connected_rig)); - // A new link is a new session for the retune rate limit, so the push below is - // treated as a first push and can never be throttled. Without this the - // reconnect case the comment below describes would silently regress: the - // requested dial still equals the last one we pushed and baseRig's cached - // freq still matches it, so a reconnect inside the reassert window would be - // suppressed and the rig would keep whatever it powered up on. + // A genuinely new link is a new session for the retune rate limit, so its + // push below must be unthrottleable — otherwise the reconnect case the + // comment below describes silently regresses. // - // Deliberately NOT reset from setOperationBand()'s not-connected branch: in - // the ~1 Hz loop this rate limit exists to contain, half the calls observe - // the rig disconnected, so resetting there would re-arm the loop every other - // iteration and defeat the fix entirely. - resetRetuneRateLimit(); + // DEBOUNCED, because this callback is itself the ~1 Hz retune driver: + // CableSerialPort fires it on every successful port open(), and the port was + // re-opening about once a second on the 2026-07-31 activation. Resetting + // unconditionally re-armed the limiter on every iteration of the very loop it + // contains, and retunes went up to 73/min from the 57/min measured before the + // limit existed. A burst of connects seconds apart is one flapping link; a + // reconnect after a real outage is minutes later. See + // RetunePolicy.CONNECT_RESET_DEBOUNCE_MS. + long connectAtMs = System.currentTimeMillis(); + if (RetunePolicy.shouldResetOnConnect(connectAtMs, lastConnectCallbackAtMs)) { + resetRetuneRateLimit(); + } + lastConnectCallbackAtMs = connectAtMs; // Push the app's current band/frequency to the rig on every connect — // including an automatic reconnect, which previously left the rig on // whatever frequency it powered up on ("no frequency set after connecting"). @@ -1534,6 +1539,8 @@ public boolean tryStartTuneViaAtu() { private long lastBandPushAtMs = 0L; private long lastRetuneSuppressionLogAtMs = RetunePolicy.NEVER_LOGGED; private int suppressedRetunes = 0; + /** When the previous onConnected() callback arrived; see RetunePolicy.shouldResetOnConnect. */ + private volatile long lastConnectCallbackAtMs = RetunePolicy.NO_CONNECT; /** * Forget what we last pushed, so the next {@code setOperationBand()} is treated as a diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableConnector.java b/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableConnector.java index cf91baf94..fe27a49aa 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableConnector.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableConnector.java @@ -32,6 +32,20 @@ public interface OnCableDataReceived { // auto-reconnect first (CatReconnectPolicy). private volatile boolean userDisconnected = false; private volatile boolean reconnecting = false; + // Burst state for the escalating backoff. reconnectAttempt PERSISTS across successful + // opens and resets only once a connection has held for + // CatReconnectPolicy.STABLE_CONNECTION_MS — a port that opens is not a link that works, + // and treating an open as success is what pinned the backoff at its first step and + // produced 13,190 port opens in 88 minutes. + // + // AtomicInteger, not a volatile int: the increment below runs on the + // CAT-Auto-Reconnect thread while handleSerialError() (serial IO thread) and + // connect() (UI thread) reset it. `++` on a volatile is a read-modify-write and is + // NOT atomic, so a lost update would hold the counter down — pinning the backoff near + // BASE_BACKOFF_MS and reviving the exact storm this guards against. + private final java.util.concurrent.atomic.AtomicInteger reconnectAttempt = + new java.util.concurrent.atomic.AtomicInteger(); + private volatile long portOpenedAtMs = 0L; public CableConnector(Context context, CableSerialPort.SerialPort serialPort, int baudRate //, int controlMode) { @@ -65,13 +79,25 @@ public void onRunError(Exception e) { */ private void handleSerialError(Exception e) { CatReconnectPolicy.Kind kind = CatReconnectPolicy.classify(e); - switch (CatReconnectPolicy.decide(userDisconnected, kind, 0)) { + // Only a connection that actually HELD counts as a recovery. Without this the + // burst restarted on every open and the backoff never escalated past its first + // 500 ms step, which is the reconnect storm this guards against. + // Reset and snapshot as one logical step so the value handed to decide() is the + // one this call established, not whatever the reconnect thread has since reached. + int attemptsSoFar; + if (CatReconnectPolicy.shouldResetBurst(System.currentTimeMillis(), portOpenedAtMs)) { + reconnectAttempt.set(0); + attemptsSoFar = 0; + } else { + attemptsSoFar = reconnectAttempt.get(); + } + switch (CatReconnectPolicy.decide(userDisconnected, kind, attemptsSoFar)) { case IGNORE: // User asked to disconnect; closing the port interrupts the blocking // read with an IOException we expect — don't surface "Lost connection". return; case RECONNECT: - startAutoReconnect(e); + startAutoReconnect(); return; case SURFACE: default: @@ -84,7 +110,7 @@ private void surfaceLostConnection(Exception e) { "Lost connection to serial port: " + (e == null ? "" : e.getMessage())); } - private synchronized void startAutoReconnect(final Exception firstError) { + private synchronized void startAutoReconnect() { if (reconnecting) return; // a burst is already being handled reconnecting = true; // Reflect an in-progress reconnect rather than an error so the UI doesn't @@ -92,13 +118,14 @@ private synchronized void startAutoReconnect(final Exception firstError) { getOnConnectorStateChanged().onConnecting(); new Thread(() -> { try { - for (int attempt = 1; - CatReconnectPolicy.shouldAutoReconnect( - CatReconnectPolicy.Kind.TRANSIENT, attempt - 1); - attempt++) { - if (userDisconnected) { - return; - } + // Unbounded by design: giving up would strand the operator with no CAT + // until they noticed the retry chip. The backoff escalates to + // MAX_BACKOFF_MS and stays there, so a persistently flaky link costs one + // attempt every 8 s instead of two per second. + while (!userDisconnected) { + // The burst counter persists across opens, so a link that keeps + // dropping keeps escalating instead of resetting to the first step. + int attempt = reconnectAttempt.incrementAndGet(); long backoff = CatReconnectPolicy.backoffMs(attempt); Log.d(TAG, "CAT auto-reconnect attempt " + attempt + " in " + backoff + "ms"); @@ -121,13 +148,14 @@ private synchronized void startAutoReconnect(final Exception firstError) { cableSerialPort.disconnect(); return; } - Log.d(TAG, "CAT auto-reconnect succeeded on attempt " + attempt); + // NOT a burst reset: the port is open, which is not yet proof the + // link works. handleSerialError() resets the counter only once + // this connection has held for STABLE_CONNECTION_MS. + portOpenedAtMs = System.currentTimeMillis(); + Log.d(TAG, "CAT port re-opened on attempt " + attempt); return; // connect() already fired onConnected() } } - // Budget exhausted — hand back to the manual retry state. - Log.e(TAG, "CAT auto-reconnect exhausted; surfacing manual retry"); - surfaceLostConnection(firstError); } finally { reconnecting = false; } @@ -228,9 +256,14 @@ public void setOnCableDataReceived(OnCableDataReceived onCableDataReceived) { @Override public void connect() { userDisconnected = false; + // A user-initiated connect is a fresh start: clear any escalation left over from a + // previous flapping session so the first glitch is again absorbed at 500 ms. + reconnectAttempt.set(0); super.connect(); getOnConnectorStateChanged().onConnecting(); - cableSerialPort.connect(); + if (cableSerialPort.connect()) { + portOpenedAtMs = System.currentTimeMillis(); + } } @Override diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/connector/CatReconnectPolicy.java b/ft8af/app/src/main/java/com/k1af/ft8af/connector/CatReconnectPolicy.java index ce7c759ea..97ffc832e 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/connector/CatReconnectPolicy.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/connector/CatReconnectPolicy.java @@ -17,9 +17,11 @@ * {@link Exception}s are a brief I/O stall the device recovers from by * re-enumerating with the same VID/PID. Only a message that names the * device as genuinely gone or access-denied is fatal. - *

  • Should we auto-reconnect, and after how long? A bounded number of - * attempts with exponential backoff. If they are exhausted we surface the - * manual retry state; a single glitch is absorbed silently.
  • + *
  • Should we auto-reconnect, and after how long? A transient error + * retries indefinitely with exponential backoff, escalating to + * {@link #MAX_BACKOFF_MS} and staying there — containment comes from the + * interval, not from giving up. A single glitch is absorbed silently. Only a + * FATAL classification surfaces the manual retry state.
  • * */ public final class CatReconnectPolicy { @@ -38,9 +40,9 @@ public enum Kind { public enum Action { /** A deliberate user disconnect caused it — expected, do nothing. */ IGNORE, - /** Transient glitch with budget left — attempt a bounded auto-reconnect. */ + /** Transient glitch — auto-reconnect with escalating backoff. */ RECONNECT, - /** Fatal, or reconnect budget exhausted — surface the manual retry state. */ + /** Fatal (device gone / access denied) — surface the manual retry state. */ SURFACE } @@ -50,12 +52,13 @@ public enum Action { *

    A deliberate user disconnect closes the port to interrupt the blocking * read, which itself raises an {@code IOException} on the read loop. That * error is expected and must be ignored rather than surfaced as a - * "Lost connection" error state. Otherwise a transient error with reconnect - * budget left reconnects; a fatal error or an exhausted budget surfaces. + * "Lost connection" error state. Otherwise a transient error reconnects (always — + * see {@link #BACKOFF_ESCALATION_ATTEMPTS}); only a fatal error surfaces. * * @param userDisconnected whether the user asked to disconnect * @param kind the classification of the error - * @param attemptsSoFar auto-reconnects already tried this burst ({@code 0} first) + * @param attemptsSoFar auto-reconnects already tried this burst ({@code 0} first); + * no longer gates the outcome, retained for callers that log it */ public static Action decide(boolean userDisconnected, Kind kind, int attemptsSoFar) { if (userDisconnected) return Action.IGNORE; @@ -64,11 +67,54 @@ public static Action decide(boolean userDisconnected, Kind kind, int attemptsSoF } /** - * Most bounded auto-reconnect attempts before falling back to the manual - * tap to retry chip. Chosen so a device that re-enumerates within a - * few seconds is picked back up, but a truly-gone cable stops churning. + * How long a freshly-opened port must survive before the link counts as recovered and + * the escalating backoff resets. + * + *

    This is the fix for the reconnect storm measured on the 2026-07-31 activation: + * {@code CableConnector} treated {@code connect()} returning true — the port merely + * opening — as success, ended the burst there, and reset the escalation. With + * a link that opened and immediately errored again, every error therefore started a + * fresh burst at attempt 1, so the backoff never got past its first step. Result: + * 13,190 port opens in 88 minutes (2.5/s), inter-arrival pinned at + * 0.51–0.53 s — exactly {@link #BASE_BACKOFF_MS} plus the open — and the + * budget-exhausted path never reached once. + * + *

    A port that opens is not a link that works. Only elapsed time proves that, so the + * burst now persists across opens and resets only after the connection has genuinely + * held for this long. */ - public static final int MAX_AUTO_RECONNECT_ATTEMPTS = 5; + public static final long STABLE_CONNECTION_MS = 10_000; + + /** + * Whether a connection that has just failed had lasted long enough to count as a + * recovery, meaning the next failure starts a fresh escalation rather than continuing + * the previous burst. + * + * @param nowMs when the failure arrived + * @param connectedAtMs when the port was last opened, or {@code 0} if never + */ + public static boolean shouldResetBurst(long nowMs, long connectedAtMs) { + if (connectedAtMs <= 0) return true; + long held = nowMs - connectedAtMs; + // A backwards clock correction must not make a brief connection look stable. + if (held < 0) return false; + return held >= STABLE_CONNECTION_MS; + } + + /** + * Attempts over which the backoff escalates before pinning at {@link #MAX_BACKOFF_MS}. + * + *

    No longer a give-up budget. A transient error now retries indefinitely, because + * giving up strands the operator: the storm this policy failed to prevent was at least + * landing CAT commands intermittently, and surfacing the manual tap to retry + * chip mid-activation would have taken CAT away entirely until the user noticed and + * tapped. Escalating to one attempt per {@link #MAX_BACKOFF_MS} is a ~20x reduction in + * churn (2.5/s measured, down to 0.125/s) while the link still recovers unattended. + * + *

    A FATAL classification still surfaces immediately — a device that has left the bus + * or refused permission will not come back by retrying. + */ + public static final int BACKOFF_ESCALATION_ATTEMPTS = 5; /** First backoff step; also the debounce window that swallows a lone glitch. */ public static final long BASE_BACKOFF_MS = 500; @@ -103,13 +149,17 @@ public static Kind classify(Exception e) { /** * Whether an automatic reconnect should be attempted. * + *

    Transient errors retry without limit — see {@link #BACKOFF_ESCALATION_ATTEMPTS} + * for why the budget was dropped. {@code attemptsSoFar} no longer gates the decision; + * it survives only so callers reading this alongside {@link #backoffMs} see the same + * burst counter, and so the signature stays stable for existing callers. + * * @param kind the classification of the error * @param attemptsSoFar how many auto-reconnects have already been tried in * this burst ({@code 0} on the first error) */ public static boolean shouldAutoReconnect(Kind kind, int attemptsSoFar) { - if (kind == Kind.FATAL) return false; - return attemptsSoFar < MAX_AUTO_RECONNECT_ATTEMPTS; + return kind != Kind.FATAL; } /** diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/location/GpsClockUpdater.java b/ft8af/app/src/main/java/com/k1af/ft8af/location/GpsClockUpdater.java index 1c821a44f..370748dfe 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/location/GpsClockUpdater.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/location/GpsClockUpdater.java @@ -59,24 +59,28 @@ public class GpsClockUpdater extends LocationSubscriber { */ static final long MAX_SANE_OFFSET_MS = 60L * 1000L; - /** - * Largest jump allowed between consecutive GPS-applied offsets within one session. - * - *

    The absolute bound above cannot catch the failure that actually bites: a single - * bad fix whose implied correction is small enough to look plausible but large enough - * to slide the FT8 grid off the air. On the 2026-07-30 activation the app spent two - * stretches transmitting 5.06 s and 7.63 s off grid — every over in them keyed up - * past the 2.36 s audio slack, so {@code lateStartSkipMs} clipped the leading Costas - * sync array out of 13.5% of that session's transmissions: loud on the air, invisible - * to receivers. - * - *

    Physics makes this cheap to detect. GPS time does not jump, and a device clock - * drifts on the order of milliseconds between fixes minutes apart. A multi-second - * STEP is therefore bad data by definition, whatever its absolute value. Only the - * first fix of a session gets to move the clock freely (there is no baseline to - * compare against, and that fix is the one legitimately correcting real drift). - */ - static final long MAX_OFFSET_STEP_MS = 500L; + // A step bound (reject a fix that moves the applied offset by more than 500 ms) was + // added here and REMOVED again after one activation, because it was actively harmful. + // + // The reasoning behind it — GPS time does not jump, so a multi-second step is bad + // data — is sound in isolation and wrong as a rule, because it has no way to + // distinguish "this fix is bad" from "the baseline is bad". Pairing it with an + // unconstrained first fix made the first fix of a run permanent: on the 2026-07-31 + // activation a cold fix 2.6 s after startup applied -1331 ms, and the next ELEVEN + // fixes — spread over an hour and all agreeing on about -2200 ms — were each refused + // for being ~870 ms away from it. The clock stayed wrong for the whole session: + // decodes fell to 5.9/cycle against 8.7 immediately after GPS finally got through, + // and transmit timing scattered (14 of 110 overs keyed up 5 s or more into the slot). + // + // One outlier is noise; eleven consecutive fixes agreeing with each other and + // disagreeing with the baseline are the truth, and the rule could never act on that. + // Rejecting a correction is not the safe default it looks like — a stale offset is + // just as capable of putting the grid off the air as a bad fix is, and unlike a bad + // fix it never self-corrects. + // + // If step-limiting is revisited, it must be able to re-baseline: e.g. accept a run of + // consecutive fixes that agree with each other and disagree with the applied offset. + // Don't reintroduce a bare step check. /** Configurable update-interval bounds (minutes), per issue #373. */ static final int MIN_INTERVAL_MINUTES = 1; @@ -258,9 +262,7 @@ private void applyFix(Location location) { fixUtcMs, fixElapsedNanos, nowElapsedNanos, - nowSystemMs, - hasAppliedOffset, - lastAppliedOffsetMs); + nowSystemMs); if (offsetMs == null) { Log.d(TAG, "Ignoring GPS fix (not running or implausible): fixUtc=" + fixUtcMs); @@ -271,7 +273,7 @@ private void applyFix(Location location) { if (isRunning()) { GeneralVariables.fileLog("GpsClock: REJECTED fix offset=" + rawOffsetMs + "ms (prior=" + (hasAppliedOffset ? lastAppliedOffsetMs + "ms" : "none") - + ", absMax=" + MAX_SANE_OFFSET_MS + "ms, maxStep=" + MAX_OFFSET_STEP_MS + + ", absMax=" + MAX_SANE_OFFSET_MS + "ms) — clock left at " + UtcTimer.delay + "ms"); } return; @@ -330,22 +332,6 @@ static boolean isOffsetSane(long fixUtcMs, int offsetMs) { return Math.abs((long) offsetMs) <= MAX_SANE_OFFSET_MS; } - /** - * Whether {@code offsetMs} is a believable successor to the offset already applied - * this session. See {@link #MAX_OFFSET_STEP_MS} for why a step bound catches what the - * absolute bound cannot. - * - * @param hasPriorOffset whether GPS has already disciplined the clock this session; - * false for the first fix, which is unconstrained by this check - * @param priorOffsetMs the offset currently applied (meaningless when no prior) - * @param offsetMs the offset this fix implies - */ - static boolean isOffsetStepSane(boolean hasPriorOffset, int priorOffsetMs, int offsetMs) { - if (!hasPriorOffset) return true; - long step = Math.abs((long) offsetMs - (long) priorOffsetMs); - return step <= MAX_OFFSET_STEP_MS; - } - /** Coerce a configured update interval into the allowed range (minutes). */ public static int clampIntervalMinutes(int minutes) { if (minutes < MIN_INTERVAL_MINUTES) return MIN_INTERVAL_MINUTES; @@ -392,12 +378,10 @@ static boolean shouldResubscribe(boolean running, long subscribedIntervalMs, lon * without a {@link LocationManager}. */ static Integer computeAppliedOffset(boolean running, long fixUtcMs, long fixElapsedRealtimeNanos, - long nowElapsedRealtimeNanos, long nowSystemMs, - boolean hasPriorOffset, int priorOffsetMs) { + long nowElapsedRealtimeNanos, long nowSystemMs) { if (!running) return null; int offsetMs = gpsClockOffsetMs(fixUtcMs, fixElapsedRealtimeNanos, nowElapsedRealtimeNanos, nowSystemMs); if (!isOffsetSane(fixUtcMs, offsetMs)) return null; - if (!isOffsetStepSane(hasPriorOffset, priorOffsetMs, offsetMs)) return null; return offsetMs; } } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/RetunePolicy.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/RetunePolicy.java index 9b3819f94..abf3ebcc3 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/RetunePolicy.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/RetunePolicy.java @@ -39,6 +39,30 @@ public final class RetunePolicy { /** Minimum gap between "suppressed N retunes" log lines, so the fix can't itself spam. */ public static final long SUPPRESSION_LOG_INTERVAL_MS = 10_000L; + /** + * How far apart two {@code onConnected()} callbacks must be to count as two separate + * connections for the purpose of re-arming the rate limit. + * + *

    Resetting on every {@code onConnected()} looked right — a new link genuinely is a + * new session — but the 2026-07-31 activation showed that callback IS the runaway. The + * suppression log named it directly: + * {@code caller=com.k1af.ft8af.MainViewModel$1$$ExternalSyntheticLambda0.run:0}, i.e. + * the {@code MainViewModel.this::setOperationBand} posted from {@code onConnected()}. + * {@code CableSerialPort} fires that callback on every successful port {@code open()}, + * and the port was re-opening about once a second, so the reset re-armed the limiter on + * every iteration of the loop it exists to contain — retunes went UP, to 73/min from + * the 57/min measured before the rate limit existed. + * + *

    Debouncing distinguishes the two cases without needing to know why the port + * churns: a burst of connects seconds apart is one flapping link and must not re-arm + * anything, while a reconnect after a real outage is minutes later and should. The + * underlying re-open storm is a separate bug still to be fixed. + */ + public static final long CONNECT_RESET_DEBOUNCE_MS = 30_000L; + + /** {@link #shouldResetOnConnect} sentinel for "no connect seen yet this run". */ + public static final long NO_CONNECT = Long.MIN_VALUE; + /** {@link #shouldRetune} sentinel for "nothing pushed yet this session". */ public static final long NO_PUSH = 0L; @@ -92,6 +116,19 @@ public static boolean shouldRetune(long requestedFreq, long rigFreq, long lastPu return elapsedSince(nowMs, lastPushAtMs) >= REASSERT_INTERVAL_MS; } + /** + * Whether this {@code onConnected()} represents a genuinely new connection, and so + * should re-arm the retune rate limit. See {@link #CONNECT_RESET_DEBOUNCE_MS}. + * + * @param nowMs current wall clock + * @param lastConnectAtMs when the previous connect callback arrived, or + * {@link #NO_CONNECT} if this is the first + */ + public static boolean shouldResetOnConnect(long nowMs, long lastConnectAtMs) { + if (lastConnectAtMs == NO_CONNECT) return true; + return elapsedSince(nowMs, lastConnectAtMs) >= CONNECT_RESET_DEBOUNCE_MS; + } + /** * Whether enough time has passed to emit another suppression summary. * diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/connector/CatReconnectPolicyTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/connector/CatReconnectPolicyTest.java index a6db54187..e3700691c 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/connector/CatReconnectPolicyTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/connector/CatReconnectPolicyTest.java @@ -15,6 +15,9 @@ */ public class CatReconnectPolicyTest { + /** Fixed epoch-like instant for the burst-timing cases. */ + private static final long T0 = 1_700_000_000_000L; + // ---- classify ----------------------------------------------------------- @Test @@ -50,13 +53,17 @@ public void deviceGoneMessages_areFatal() { public void autoReconnect_transientWithinBudget_allowed() { assertThat(CatReconnectPolicy.shouldAutoReconnect(Kind.TRANSIENT, 0)).isTrue(); assertThat(CatReconnectPolicy.shouldAutoReconnect( - Kind.TRANSIENT, CatReconnectPolicy.MAX_AUTO_RECONNECT_ATTEMPTS - 1)).isTrue(); + Kind.TRANSIENT, CatReconnectPolicy.BACKOFF_ESCALATION_ATTEMPTS - 1)).isTrue(); } @Test - public void autoReconnect_budgetExhausted_denied() { + public void autoReconnect_transientRetriesWithoutLimit() { + // The budget was dropped deliberately: giving up strands the operator with no CAT + // until they notice the retry chip. Containment comes from the backoff escalating + // to MAX_BACKOFF_MS and staying there, not from stopping. assertThat(CatReconnectPolicy.shouldAutoReconnect( - Kind.TRANSIENT, CatReconnectPolicy.MAX_AUTO_RECONNECT_ATTEMPTS)).isFalse(); + Kind.TRANSIENT, CatReconnectPolicy.BACKOFF_ESCALATION_ATTEMPTS)).isTrue(); + assertThat(CatReconnectPolicy.shouldAutoReconnect(Kind.TRANSIENT, 1_000)).isTrue(); } @Test @@ -144,9 +151,66 @@ public void decide_fatal_surfaces() { } @Test - public void decide_transientBudgetExhausted_surfaces() { + public void decide_transientKeepsReconnectingPastTheOldBudget() { assertThat(CatReconnectPolicy.decide( - false, Kind.TRANSIENT, CatReconnectPolicy.MAX_AUTO_RECONNECT_ATTEMPTS)) - .isEqualTo(CatReconnectPolicy.Action.SURFACE); + false, Kind.TRANSIENT, CatReconnectPolicy.BACKOFF_ESCALATION_ATTEMPTS)) + .isEqualTo(CatReconnectPolicy.Action.RECONNECT); + } + + // ---- shouldResetBurst: the reconnect-storm fix ------------------------- + + @Test + public void burst_neverConnected_resets() { + assertThat(CatReconnectPolicy.shouldResetBurst(T0, 0L)).isTrue(); + } + + @Test + public void burst_portThatDiesImmediatelyDoesNotReset() { + // THE bug. CableConnector treated connect() returning true as success and reset + // the escalation there, so a port that opened and immediately errored restarted + // the burst at attempt 1 every time — backoff pinned at BASE_BACKOFF_MS (500 ms). + // Measured result: 13,190 port opens in 88 minutes, inter-arrival 0.51-0.53 s, + // and the give-up path never reached once. + assertThat(CatReconnectPolicy.shouldResetBurst(T0 + 30, T0)).isFalse(); + assertThat(CatReconnectPolicy.shouldResetBurst(T0 + 530, T0)).isFalse(); + } + + @Test + public void burst_connectionThatHeldResets() { + assertThat(CatReconnectPolicy.shouldResetBurst( + T0 + CatReconnectPolicy.STABLE_CONNECTION_MS, T0)).isTrue(); + } + + @Test + public void burst_justUnderStableDoesNotReset() { + assertThat(CatReconnectPolicy.shouldResetBurst( + T0 + CatReconnectPolicy.STABLE_CONNECTION_MS - 1, T0)).isFalse(); + } + + @Test + public void burst_backwardsClockDoesNotFakeStability() { + // System.currentTimeMillis() is not monotonic and this app disciplines its own + // clock; a backwards correction must not make a 30 ms connection look stable. + assertThat(CatReconnectPolicy.shouldResetBurst(T0 - 60_000, T0)).isFalse(); + } + + @Test + public void escalationConstantMatchesWhenTheCeilingIsReached() { + // BACKOFF_ESCALATION_ATTEMPTS is no longer a give-up budget, so it only earns its + // place by describing something real: the attempt at which backoff hits the + // ceiling. Pin that, or the name drifts from the behaviour again. + assertThat(CatReconnectPolicy.backoffMs(CatReconnectPolicy.BACKOFF_ESCALATION_ATTEMPTS)) + .isEqualTo(CatReconnectPolicy.MAX_BACKOFF_MS); + assertThat(CatReconnectPolicy.backoffMs(CatReconnectPolicy.BACKOFF_ESCALATION_ATTEMPTS - 1)) + .isLessThan(CatReconnectPolicy.MAX_BACKOFF_MS); + } + + @Test + public void burst_escalationReachesTheCeilingQuickly() { + // With the counter persisting, a flapping link walks up to the ceiling in a + // handful of attempts instead of sitting at the first step forever. + assertThat(CatReconnectPolicy.backoffMs(1)).isEqualTo(CatReconnectPolicy.BASE_BACKOFF_MS); + assertThat(CatReconnectPolicy.backoffMs(5)).isEqualTo(CatReconnectPolicy.MAX_BACKOFF_MS); + assertThat(CatReconnectPolicy.backoffMs(50)).isEqualTo(CatReconnectPolicy.MAX_BACKOFF_MS); } } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/location/GpsClockUpdaterTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/location/GpsClockUpdaterTest.java index f00a73583..2a3309d16 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/location/GpsClockUpdaterTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/location/GpsClockUpdaterTest.java @@ -106,74 +106,31 @@ public void sane_absoluteBoundIsTightEnoughForFt8() { assertThat(GpsClockUpdater.MAX_SANE_OFFSET_MS).isAtLeast(30_000L); } - // ---- isOffsetStepSane (the bound that catches a plausible-looking bad fix) ---- - - @Test - public void step_firstFixIsUnconstrained() { - // No baseline yet, and this is the fix that legitimately corrects accumulated - // drift — it must be allowed to move the clock by a lot. - assertThat(GpsClockUpdater.isOffsetStepSane(false, 0, 45_000)).isTrue(); - assertThat(GpsClockUpdater.isOffsetStepSane(false, 0, -45_000)).isTrue(); - } - - @Test - public void step_rejectsTheMultiSecondJumpThatWentOnAir() { - // The 2026-07-30 activation: the grid slid 5.06 s and 7.63 s, and every over in - // those stretches keyed up past the audio slack with its leading Costas array - // clipped. Both are within the absolute bound, so only the step check stops them. - assertThat(GpsClockUpdater.isOffsetStepSane(true, 0, 5_060)).isFalse(); - assertThat(GpsClockUpdater.isOffsetStepSane(true, 0, 7_630)).isFalse(); - // ...and each is still "sane" by the absolute bound alone, which is the point. - assertThat(GpsClockUpdater.isOffsetSane(1_700_000_000_000L, 5_060)).isTrue(); - } - - @Test - public void step_acceptsOrdinaryDriftBetweenFixes() { - // A device clock drifts milliseconds between fixes minutes apart. - assertThat(GpsClockUpdater.isOffsetStepSane(true, 120, 145)).isTrue(); - assertThat(GpsClockUpdater.isOffsetStepSane(true, -80, -60)).isTrue(); - } - - @Test - public void step_boundaryIsInclusive() { - int prior = 100; - int atBound = prior + (int) GpsClockUpdater.MAX_OFFSET_STEP_MS; - assertThat(GpsClockUpdater.isOffsetStepSane(true, prior, atBound)).isTrue(); - assertThat(GpsClockUpdater.isOffsetStepSane(true, prior, atBound + 1)).isFalse(); - } - - @Test - public void step_measuredAgainstPriorNotZero() { - // A large but STABLE offset must keep being accepted: once the first fix has - // corrected a badly-unsynced clock, later fixes near that value are correct and - // must not be rejected for being far from zero. - assertThat(GpsClockUpdater.isOffsetStepSane(true, 40_000, 40_050)).isTrue(); - } - - @Test - public void step_rejectsJumpBackToZeroFromLargeOffset() { - // The mirror image: having disciplined to +40 s, a fix implying ~0 is a bad fix, - // not the clock spontaneously fixing itself. - assertThat(GpsClockUpdater.isOffsetStepSane(true, 40_000, 0)).isFalse(); - } - - // ---- computeAppliedOffset with the step bound wired in ---- - - @Test - public void appliedOffset_nullWhenStepTooLarge() { - // Running, absolute-sane, but a 3 s jump from the applied offset: dropped. + // ---- regression: a consistent correction must never be refused ---- + + @Test + public void aMultiSecondCorrectionIsApplied_notRefusedForBeingFarFromThePrevious() { + // The 2026-07-31 activation, reproduced: a cold fix 2.6s after startup applied + // -1331ms, then eleven fixes over the following hour all implied about -2200ms + // and were each refused by a 500ms step bound for being ~870ms away from it. The + // clock stayed ~870ms wrong all session — decodes 5.9/cycle against 8.7 right + // after GPS finally got through, and 14 of 110 overs keying up 5s+ into the slot. + // + // A stale offset puts the FT8 grid off the air just as surely as a bad fix does, + // and unlike a bad fix it never self-corrects. Within the absolute bound, apply. Integer r = GpsClockUpdater.computeAppliedOffset( - true, 11_000L, 5000L * MS, 5000L * MS, 8_000L, true, 0); - assertThat(r).isNull(); + true, 10_000L - 2_200L, 5000L * MS, 5000L * MS, 10_000L); + assertThat(r).isEqualTo(-2_200); } @Test - public void appliedOffset_allowsSmallStepFromPrior() { - // Same fix geometry as appliedOffset_returnsOffsetWhenRunningAndSane (+2000), - // with a prior offset close enough that the step passes. - Integer r = GpsClockUpdater.computeAppliedOffset( - true, 10_000L, 5000L * MS, 5000L * MS, 8_000L, true, 1_800); - assertThat(r).isEqualTo(2_000); + public void repeatedAgreeingFixesAllApply() { + // Whatever the previously applied offset was, each of a consistent run is applied. + for (int impliedOffset : new int[] {-2_112, -2_159, -2_143, -2_156, -2_213}) { + Integer r = GpsClockUpdater.computeAppliedOffset( + true, 10_000L + impliedOffset, 5000L * MS, 5000L * MS, 10_000L); + assertThat(r).isEqualTo(impliedOffset); + } } // ---- clampIntervalMinutes ---- @@ -236,21 +193,21 @@ public void resubscribe_trueWhenCadenceChanges() { @Test public void appliedOffset_nullWhenNotRunning() { // A fix that raced past a disable (running flipped false) must not touch the clock. - Integer r = GpsClockUpdater.computeAppliedOffset(false, 10_000L, 5000L * MS, 5000L * MS, 8_000L, false, 0); + Integer r = GpsClockUpdater.computeAppliedOffset(false, 10_000L, 5000L * MS, 5000L * MS, 8_000L); assertThat(r).isNull(); } @Test public void appliedOffset_nullWhenInsane() { // fixUtc==0 (no time in the fix) is rejected even while running. - Integer r = GpsClockUpdater.computeAppliedOffset(true, 0L, 5000L * MS, 5000L * MS, 8_000L, false, 0); + Integer r = GpsClockUpdater.computeAppliedOffset(true, 0L, 5000L * MS, 5000L * MS, 8_000L); assertThat(r).isNull(); } @Test public void appliedOffset_returnsOffsetWhenRunningAndSane() { // GPS UTC now = 10_000 (fresh fix), device reads 8_000 => +2_000. - Integer r = GpsClockUpdater.computeAppliedOffset(true, 10_000L, 5000L * MS, 5000L * MS, 8_000L, false, 0); + Integer r = GpsClockUpdater.computeAppliedOffset(true, 10_000L, 5000L * MS, 5000L * MS, 8_000L); assertThat(r).isEqualTo(2_000); } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/RetunePolicyTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/RetunePolicyTest.java index 6e169d219..95d4d3174 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/RetunePolicyTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/RetunePolicyTest.java @@ -46,6 +46,44 @@ public void rigOnAWrongFrequencyIsCorrectedImmediately() { FREQ, OTHER_FREQ, FREQ, T0 + 10, T0)).isTrue(); } + // --------------------------------------------------------------- + // Connect-reset debounce (onConnected IS the ~1 Hz retune driver) + // --------------------------------------------------------------- + + @Test + public void firstConnectResets() { + assertThat(RetunePolicy.shouldResetOnConnect(T0, RetunePolicy.NO_CONNECT)).isTrue(); + } + + @Test + public void aFlappingPortDoesNotReArmTheRateLimit() { + // The measured failure: CableSerialPort fires onConnected() on every successful + // port open() and the port was re-opening about once a second, so an + // unconditional reset re-armed the limiter on every iteration of the loop it + // exists to contain — retunes rose to 73/min from the 57/min measured before the + // rate limit existed. + assertThat(RetunePolicy.shouldResetOnConnect(T0 + 1_000, T0)).isFalse(); + assertThat(RetunePolicy.shouldResetOnConnect(T0 + 5_000, T0)).isFalse(); + } + + @Test + public void aReconnectAfterARealOutageResets() { + assertThat(RetunePolicy.shouldResetOnConnect( + T0 + RetunePolicy.CONNECT_RESET_DEBOUNCE_MS, T0)).isTrue(); + } + + @Test + public void connectDebounceToleratesBackwardsTime() { + assertThat(RetunePolicy.shouldResetOnConnect(T0 - 60_000, T0)).isTrue(); + } + + @Test + public void connectDebounceOutlastsTheObservedFlapRate() { + // Must be far above the ~1 s flap; the reassert heartbeat and the rig-freq check + // cover a genuine reconnect that lands inside the window. + assertThat(RetunePolicy.CONNECT_RESET_DEBOUNCE_MS).isAtLeast(10_000L); + } + @Test public void reconnectIsNotThrottled_whenStateIsResetOnConnect() { // MainViewModel.onConnected() calls resetRetuneRateLimit(), which restores From 7bf3a767c32bf695824d4853c71aba5c5020fc0d Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Fri, 31 Jul 2026 19:39:41 -0500 Subject: [PATCH 099/113] Stop discarding fast-pass decodes that land after key-up (#709) * Stop discarding fast-pass decodes that land after key-up The app was not seeing half the stations calling it. Measured on the 2026-07-31 activation: of 66 cycles where someone addressed us (replyToMe=true in debug.log), 34 -- 52% -- never reached the auto sequencer at all. They decoded correctly and were thrown away, so the operator kept calling CQ at people who were answering and had to pick callers by hand. MainViewModel.afterDecode gated the fast-pass parse on three conditions and silently dropped the decode when any failed: if (!isTransmitting && !isDeep && replyCost <= budget) { parseMessageToFunction(messages); } Two of those drop live evidence. The decisive one is isTransmitting: a fast decode is delivered about earlyDecodeMillis plus decode time into the slot, so a ~2s decode lands a few hundred ms PAST the boundary -- and key-up happens within the first half second. Measured gap between key-up and the following delivery: 55 deliveries landed 0-0.4s AFTER it. From the log, four consecutive cycles of exactly this: 16:39:01.841 QSO: TX msg=[CQ POTA K1AF EM28] 16:39:02.101 DECODE: kept=14 replyToMe=true <- 0.26s late, dropped 16:39:31.842 QSO: TX msg=[CQ POTA K1AF EM28] 16:40:01.835 QSO: TX msg=[CQ POTA K1AF EM28] 16:40:31.840 QSO: TX msg=[CQ POTA K1AF EM28] 16:41:01.841 QSO: TX msg=[W3HH K1AF -16] <- only after the operator stepped in "enqueue caller" fired twice in the whole session against 66 cycles of people calling. Deep passes landing in that same window were ALREADY stashed and replayed; the fast pass -- the one carrying the timely reply -- had no such path. It does now, via the existing PendingSequencerDecodes (which already ages and evicts). Replay runs through the evidence-only parse, which still answers a station calling us: checkCQMeOrFollowCQMessage is invoked ABOVE the evidenceOnly guard in parseMessageToFunctionInner. Only absence-of-evidence decisions stay suppressed, correctly -- this cycle's no-reply call was already made. The over-budget branch is stashed too, for the same reason: too slow to key up this cycle does not make the evidence worthless next cycle. Both drops were also invisible in debug.log, which is why this survived four PRs of chasing adjacent problems. Both now log. Decision extracted to FastPassDisposition so it is unit-tested; its contract is that there is no third outcome -- every delivery either parses now or is stashed for replay, and nothing is dropped. Co-Authored-By: Claude Opus 5 (1M context) * Address Copilot review: sample TX state once, and de-duplicate the rationale 1. isTransmitting() was read twice -- once for the decision, once for the branch that words the log line -- so a flip between them would have produced a stash logged with the wrong reason. Given this whole class of bug survived four PRs precisely because the drop was invisible in debug.log, a diagnostic that can lie about why is not a small thing. Sampled once into a local now, and the two stash branches collapsed into one with the message selected from that sample. 2. The activation-specific narrative in the branch duplicated the FastPassDisposition javadoc. Trimmed to a durable one-liner with the detail left in the class. The one non-obvious fact the inline comment carried is now stated in FastPassDisposition instead of being lost: replay only answers callers because parseMessageToFunctionInner calls checkCQMeOrFollowCQMessage ABOVE its evidenceOnly guard, so moving that call below the guard would silently disable this mechanism. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../java/com/k1af/ft8af/MainViewModel.java | 27 ++++-- .../ft8listener/FastPassDisposition.java | 58 +++++++++++++ .../ft8listener/FastPassDispositionTest.java | 82 +++++++++++++++++++ 3 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FastPassDisposition.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/ft8listener/FastPassDispositionTest.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 a8563fcfb..4b2f47d64 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -72,6 +72,7 @@ import com.k1af.ft8af.database.WorkedModeFilter; import com.k1af.ft8af.flex.FlexRadio; import com.k1af.ft8af.flex.RadioTcpClient; +import com.k1af.ft8af.ft8listener.FastPassDisposition; import com.k1af.ft8af.ft8listener.FT8SignalListener; import com.k1af.ft8af.ft8listener.OnFt8Listen; import com.k1af.ft8af.ft8transmit.FT8TransmitSignal; @@ -700,12 +701,26 @@ public void afterDecode(long utc, float time_sec, int sequential //check transmit procedure. Parse transmit procedure from message list //if exceeded cycle by 2 seconds, should not parse int autoReplyBudgetMs = Math.max(2000, GeneralVariables.lateStartTolerance); - if (!ft8TransmitSignal.isTransmitting() - && !isDeep//deep passes go through the evidence-only path below - && (ft8SignalListener.timeSec - + GeneralVariables.pttDelay - + GeneralVariables.transmitDelay <= autoReplyBudgetMs)) {//budget scales with late-start tolerance - ft8TransmitSignal.parseMessageToFunction(messages);//parse messages and process + if (!isDeep) {//deep passes go through the evidence-only path below + long replyCostMs = ft8SignalListener.timeSec + + GeneralVariables.pttDelay + + GeneralVariables.transmitDelay; + // Sample TX state ONCE: it is read by the decision and again by the + // branch that words the log line, and it can flip between the two. + final boolean transmitting = ft8TransmitSignal.isTransmitting(); + // Never dropped — a decode too late to act on this cycle is still + // evidence for the next one. See FastPassDisposition. + if (FastPassDisposition.decide(transmitting, replyCostMs, autoReplyBudgetMs) + == FastPassDisposition.Action.PARSE) { + ft8TransmitSignal.parseMessageToFunction(messages);//parse messages and process + } else { + pendingSequencerDecodes.stash(messages, UtcTimer.getSystemTime()); + fileLog(transmitting + ? "QSO: fast decode landed after key-up — stashed " + + messages.size() + " msg(s) for replay" + : "QSO: fast decode over reply budget (" + replyCostMs + + "ms > " + autoReplyBudgetMs + "ms) — stashed for replay"); + } } // Deep/subtraction/late passes routinely find replies the fast diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FastPassDisposition.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FastPassDisposition.java new file mode 100644 index 000000000..ca99518f3 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FastPassDisposition.java @@ -0,0 +1,58 @@ +package com.k1af.ft8af.ft8listener; + +/** + * What {@code MainViewModel.afterDecode} does with a FAST-pass decode: act on it now, or + * stash it for replay on the next delivery. + * + *

    Before this existed the answer was "act on it now, or throw it away". Two conditions + * dropped it silently — the transmitter had already keyed, or the decode had eaten the + * auto-reply budget — and neither was logged. Measured on the 2026-07-31 activation: + * 34 of the 66 cycles where a station called us (52%) were discarded that way. + * They decoded correctly ({@code replyToMe=true} in the log) and the sequencer never saw + * them, so the operator kept calling CQ at people who were answering, and had to pick + * callers by hand. + * + *

    The timing is unforgiving by construction: a fast decode is delivered about + * {@code earlyDecodeMillis} plus decode time into the slot, so a ~2 s decode puts delivery + * a few hundred milliseconds PAST the slot boundary — and therefore past key-up, which + * happens within the first half second. Deep passes landing in exactly that window were + * already stashed and replayed; the fast pass, the one carrying the timely reply, was not. + * + *

    Stashing is never wrong here: replay runs through the evidence-only parse, which still + * answers a station calling us but suppresses absence-of-evidence decisions (no-reply + * counting, give-up). Those belong to the cycle that already made them. The load-bearing + * detail is that {@code FT8TransmitSignal.parseMessageToFunctionInner} calls + * {@code checkCQMeOrFollowCQMessage} above its {@code evidenceOnly} guard — if that + * call ever moves below the guard, a replayed decode silently stops answering callers and + * this whole mechanism goes quiet again. + */ +public final class FastPassDisposition { + + private FastPassDisposition() {} + + /** What to do with this fast-pass delivery. */ + public enum Action { + /** Hand it to the sequencer now — it can still key up this cycle. */ + PARSE, + /** Too late to act on this cycle; keep it for replay as evidence next cycle. */ + STASH + } + + /** + * Decide the disposition of one fast-pass delivery. + * + *

    Ordering matters: an in-flight transmission is checked first, because once we are + * keyed the reply budget is irrelevant — no decode, however quick, can change what is + * already going out over the air. + * + * @param transmitting whether TX has already keyed for this cycle + * @param replyCostMs decode elapsed time plus the PTT and transmit delays — what it + * would cost to still get a reply out this cycle + * @param budgetMs the auto-reply budget (scales with the late-start tolerance) + */ + public static Action decide(boolean transmitting, long replyCostMs, long budgetMs) { + if (transmitting) return Action.STASH; + if (replyCostMs > budgetMs) return Action.STASH; + return Action.PARSE; + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ft8listener/FastPassDispositionTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ft8listener/FastPassDispositionTest.java new file mode 100644 index 000000000..d57f933bf --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ft8listener/FastPassDispositionTest.java @@ -0,0 +1,82 @@ +package com.k1af.ft8af.ft8listener; + +import static com.google.common.truth.Truth.assertThat; + +import com.k1af.ft8af.ft8listener.FastPassDisposition.Action; + +import org.junit.Test; + +/** + * Unit tests for {@link FastPassDisposition} — what happens to a fast-pass decode that + * arrives too late to act on this cycle. + * + *

    The bug this closes: both late cases used to DROP the decode. Measured on the + * 2026-07-31 activation, 34 of the 66 cycles where a station called us (52%) were + * discarded — decoded correctly, sequencer never saw them, operator kept calling CQ at + * people who were answering and had to pick callers by hand. + * + *

    Pure JVM: no Android types. + */ +public class FastPassDispositionTest { + + private static final long BUDGET_MS = 2_000; + + @Test + public void inTimeAndIdle_parses() { + assertThat(FastPassDisposition.decide(false, 400, BUDGET_MS)).isEqualTo(Action.PARSE); + } + + @Test + public void exactlyAtBudget_stillParses() { + // The budget is what it costs to still get a reply out; spending all of it is fine. + assertThat(FastPassDisposition.decide(false, BUDGET_MS, BUDGET_MS)).isEqualTo(Action.PARSE); + } + + @Test + public void justOverBudget_stashesRatherThanDropping() { + assertThat(FastPassDisposition.decide(false, BUDGET_MS + 1, BUDGET_MS)) + .isEqualTo(Action.STASH); + } + + @Test + public void alreadyKeyed_stashesRatherThanDropping() { + // THE case. The fast decode is delivered ~earlyDecodeMillis + decode time into the + // slot, so a ~2 s decode lands a few hundred ms past the boundary — and key-up + // happens within the first half second. This is the reply to our CQ arriving + // moments too late to act on, which is worth keeping, not discarding. + assertThat(FastPassDisposition.decide(true, 200, BUDGET_MS)).isEqualTo(Action.STASH); + } + + @Test + public void alreadyKeyedBeatsAHealthyBudget() { + // Ordering matters: once keyed, no decode however quick can change what is already + // going out over the air, so the budget must not be able to override this. + assertThat(FastPassDisposition.decide(true, 0, BUDGET_MS)).isEqualTo(Action.STASH); + } + + @Test + public void alreadyKeyedAndOverBudget_stashes() { + assertThat(FastPassDisposition.decide(true, 9_000, BUDGET_MS)).isEqualTo(Action.STASH); + } + + @Test + public void nothingIsEverDropped() { + // The whole point: every combination resolves to PARSE or STASH. There is no + // third outcome, because a decoded reply is always worth acting on — this cycle + // if we can, the next one if we can't. + for (boolean tx : new boolean[] {false, true}) { + for (long cost : new long[] {0, 400, BUDGET_MS, BUDGET_MS + 1, 30_000}) { + Action a = FastPassDisposition.decide(tx, cost, BUDGET_MS); + assertThat(a).isAnyOf(Action.PARSE, Action.STASH); + } + } + } + + @Test + public void aLargerLateStartToleranceWidensTheParseWindow() { + // autoReplyBudgetMs is max(2000, lateStartTolerance), so raising the tolerance + // should let a slower decode still reply in the same cycle. + assertThat(FastPassDisposition.decide(false, 3_000, 2_000)).isEqualTo(Action.STASH); + assertThat(FastPassDisposition.decide(false, 3_000, 4_000)).isEqualTo(Action.PARSE); + } +} From 3decd0295a8cabf302e3fba6d33207be1f1443a9 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sat, 1 Aug 2026 14:52:11 -0500 Subject: [PATCH 100/113] Stop commanding the rig a frequency it echoed back during a CAT desync (#711) * Stop commanding the rig a frequency it echoed back during a CAT desync Reported as "it takes a loooong time for the radio to change frequencies when I switch bands or modes". The app is not slow -- it dispatches the retune in 815ms. The delay is a fight afterwards: 19:54:02 bandSelect: band=10136000 <- operator taps 30m 19:54:03 serial.send: FA010136000; <- out in 815ms rig replies "?;" <- rejected 19:54:11 setting freq=10136000 (rig.getFreq=14239985) <- rig reports a value nobody asked for 19:54:29 setting freq=14239985 <- THE APP COMMANDS IT BACK 19:55:01 setting freq=10136000 (rig.getFreq=10136000) <- settles, ~59s, 4 taps 29 rig rejections that session, every one following an FA set-frequency. onFreqChanged wrote whatever the rig reported into GeneralVariables.band, which is also the value setOperationBand pushes out -- so an observation was promoted to a command, and the 30s reassert heartbeat then fought the operator's selection for as long as the bad reading survived. Split the two roles. GeneralVariables.commandedBandHz is the dial the app asserts, set by explicit choices (band picker, mode retune, config load) and by a rig report only while the CAT stream is healthy. band stays the observed value for display, logging and PSK. The trust rule is deliberately narrow, because a report we did not ask for is in general indistinguishable from the operator turning the VFO -- and fighting a manual tune would be its own bug. The one case identifiable from evidence is a report arriving while the rig is refusing our commands, so Yaesu39Rig sets a flag on an unparseable frame and CableSerialPort clears it on the next send. Known limit: this only covers desyncs that produce an unparseable frame. A rig that silently reports a wrong frequency with a well-formed FA reply is still adopted, and would still be commanded back. Co-Authored-By: Claude Opus 5 (1M context) * Address review: time-window distrust, live dial re-read Copilot review, both valid: 1. The delayed Runnable used the dialHz captured 800ms earlier, where it previously read GeneralVariables.band at execution time. A band change inside that window would therefore send the OLD frequency first and briefly retune the rig away from the newest selection -- a spurious extra FA on a rig already rejecting them. It now re-reads the commanded dial at execution time. 2. rigRejectedSinceCommand was cleared BEFORE the write was attempted, so a throwing write cleared it without the re-syncing command ever going out. Self-review found a bigger hole in the same mechanism, which subsumes (2): the flag was cleared by ANY outgoing command, and the CAT liveness watchdog polls the rig every CAT_LIVENESS_TICK_MS (3s) with a frequency read. That unrelated poll could clear the flag between the rejection and the bad report, defeating the guard entirely -- and it depended on send ordering with a component that knows nothing about it. Replaced with a timestamp: GeneralVariables.rigRejectedAtMs, and reports within RigDialTarget.DESYNC_DISTRUST_MS of a rejection are not adopted as the commanded dial. Depends on nothing but the clock, so there is no clear-path to get wrong and the CableSerialPort change is dropped entirely. A backwards clock correction does not re-trust. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../java/com/k1af/ft8af/GeneralVariables.java | 18 +++ .../java/com/k1af/ft8af/MainViewModel.java | 37 +++++- .../com/k1af/ft8af/database/DatabaseOpr.java | 2 + .../com/k1af/ft8af/rigs/RigDialTarget.java | 87 ++++++++++++ .../java/com/k1af/ft8af/rigs/Yaesu39Rig.java | 4 + .../ui/components/FrequencyPickerSheet.kt | 3 + .../k1af/ft8af/rigs/RigDialTargetTest.java | 124 ++++++++++++++++++ 7 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/rigs/RigDialTarget.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/rigs/RigDialTargetTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 4d8a93825..b377493a7 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -579,6 +579,24 @@ public static String excludedBandsToCsv() { //to UtcTimer.delay. The last-sync *timestamp* is the retained value of mutableGpsClockSync //below, so there's no separate field for it. public static volatile int gpsClockOffsetMs = 0; + + /** + * The dial the app is entitled to COMMAND, as opposed to {@link #band}, which is + * whatever the rig was last observed reporting. They used to be one field, so a bad + * reading became a command and fought the operator's band selection — see + * {@link com.k1af.ft8af.rigs.RigDialTarget}. Set by explicit selections (band picker, + * mode retune, connect-time push) and by a trusted rig report; 0 means "not yet + * established", which falls back to {@link #band}. + */ + public static volatile long commandedBandHz = 0; + + /** + * When the rig last answered with an error or unparseable frame ("?;"), meaning the CAT + * stream was desynchronised and frequencies it reports around then are not trustworthy + * enough to command back at it. A timestamp, not a flag: see + * {@link com.k1af.ft8af.rigs.RigDialTarget#DESYNC_DISTRUST_MS}. + */ + public static volatile long rigRejectedAtMs = 0L; //Posted each time a GPS fix disciplines the clock, so the Time Sync screen can recompose //its "last sync"/offset readout. Carries the sync's System.currentTimeMillis() timestamp. public static MutableLiveData mutableGpsClockSync = new MutableLiveData<>(); 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 4b2f47d64..1954a70cd 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -92,6 +92,7 @@ import com.k1af.ft8af.rigs.BaseRigOperation; import com.k1af.ft8af.rigs.CatConnectionState; import com.k1af.ft8af.rigs.RetunePolicy; +import com.k1af.ft8af.rigs.RigDialTarget; import com.k1af.ft8af.rigs.CatLiveness; import com.k1af.ft8af.rigs.DiscoveryTX500Rig; import com.k1af.ft8af.rigs.ElecraftRig; @@ -343,6 +344,20 @@ public void onFreqChanged(long freq) { // though the band is the same. Wavelength only changes on a real band hop. String oldWaveLength = BaseRigOperation.getMeterFromFreq(GeneralVariables.band); GeneralVariables.band = freq; + // Observing a frequency is not the same as choosing one. Adopt it as the dial + // we COMMAND only while the CAT stream is healthy — otherwise a reading taken + // during a "?;" desync gets pushed back at the rig by the reassert heartbeat + // and fights the operator's band selection (measured: a 30m tap took ~59s and + // four attempts because the app kept re-commanding a 14239985 it never chose). + // See RigDialTarget. + if (RigDialTarget.shouldAdoptAsTarget(System.currentTimeMillis(), + GeneralVariables.rigRejectedAtMs, freq)) { + GeneralVariables.commandedBandHz = freq; + } else { + fileLog("rig echo ignored as command target: reported " + freq + + " while CAT desynced; still asserting " + + GeneralVariables.commandedBandHz); + } GeneralVariables.bandListIndex = OperationBand.getIndexByFreq(freq); GeneralVariables.mutableBandChange.postValue(GeneralVariables.bandListIndex); String newWaveLength = BaseRigOperation.getMeterFromFreq(freq); @@ -1583,7 +1598,10 @@ public void setOperationBand() { } long nowMs = System.currentTimeMillis(); - if (!RetunePolicy.shouldRetune(GeneralVariables.band, baseRig.getFreq(), + // Assert the dial we CHOSE, never one echoed back by the rig. See RigDialTarget. + final long dialHz = RigDialTarget.dialToCommand( + GeneralVariables.commandedBandHz, GeneralVariables.band); + if (!RetunePolicy.shouldRetune(dialHz, baseRig.getFreq(), lastPushedBandFreq, nowMs, lastBandPushAtMs)) { suppressedRetunes++; if (RetunePolicy.shouldLogSuppression(nowMs, lastRetuneSuppressionLogAtMs)) { @@ -1591,7 +1609,7 @@ public void setOperationBand() { // the source, so record who is actually asking. Only on the rate-limited // path — building a stack trace per suppressed call would be its own leak. fileLog("setOperationBand: suppressed " + suppressedRetunes - + " redundant retunes (freq=" + GeneralVariables.band + + " redundant retunes (freq=" + dialHz + " already set) caller=" + RetunePolicy.callerOf( Thread.currentThread().getStackTrace(), MainViewModel.class.getName())); @@ -1600,10 +1618,10 @@ public void setOperationBand() { } return; } - lastPushedBandFreq = GeneralVariables.band; + lastPushedBandFreq = dialHz; lastBandPushAtMs = nowMs; - fileLog("setOperationBand: sending USB mode, then freq=" + GeneralVariables.band + fileLog("setOperationBand: sending USB mode, then freq=" + dialHz + " in 800ms (controlMode=" + GeneralVariables.controlMode + ")"); //set USB mode first, then set frequency baseRig.setUsbModeToRig();//set USB mode @@ -1612,9 +1630,14 @@ public void setOperationBand() { new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { @Override public void run() { - fileLog("setOperationBand: setting freq=" + GeneralVariables.band + // Re-read the commanded dial HERE rather than using the value captured + // 800ms ago: the operator can change band inside that window, and a + // stale capture would briefly retune the rig back to the old one. + long sendHz = RigDialTarget.dialToCommand( + GeneralVariables.commandedBandHz, GeneralVariables.band); + fileLog("setOperationBand: setting freq=" + sendHz + " (rig.getFreq=" + baseRig.getFreq() + ")"); - baseRig.setFreq(GeneralVariables.band);//set frequency + baseRig.setFreq(sendHz);//set frequency baseRig.setFreqToRig(); } }, 800); @@ -1672,6 +1695,8 @@ public boolean setOperatingMode(int modeId) { boolean retuned = false; if (newFreq > 0 && newFreq != GeneralVariables.band) { GeneralVariables.band = newFreq; + // Mode retune within the same band is an explicit choice too. See RigDialTarget. + GeneralVariables.commandedBandHz = newFreq; GeneralVariables.bandListIndex = OperationBand.getIndexByFreq(newFreq); databaseOpr.writeConfig("bandFreq", String.valueOf(newFreq), null); retuned = true; diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 026f5381e..e449d1122 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2652,6 +2652,8 @@ protected Void doInBackground(Void... voids) { } if (name.equalsIgnoreCase("bandFreq")) { GeneralVariables.band = parseConfigLong(result, 14074000L); + // Restored operator selection from config; seeds the commanded dial. + GeneralVariables.commandedBandHz = GeneralVariables.band; GeneralVariables.bandListIndex = OperationBand.getIndexByFreq(GeneralVariables.band); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/RigDialTarget.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/RigDialTarget.java new file mode 100644 index 000000000..798bbfae1 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/RigDialTarget.java @@ -0,0 +1,87 @@ +package com.k1af.ft8af.rigs; + +/** + * Decides which dial the app is entitled to command, as distinct from the dial it + * merely observes the rig reporting. + * + *

    Those were the same value, and that is what made a band change take a minute. Measured + * on the 2026-07-31 evening activation: + * + *

    + * 19:54:02  bandSelect: band=10136000            <- operator taps 30m
    + * 19:54:03  serial.send: FA010136000;            <- dispatched in 815 ms
    + *           rig replies "?;"                     <- rejected (29 such replies that session)
    + * 19:54:11  setting freq=10136000 (rig.getFreq=14239985)   <- rig now reports a value
    + *                                                              nobody asked for
    + * 19:54:29  setting freq=14239985                <- THE APP COMMANDS IT BACK
    + * 19:55:01  setting freq=10136000 (rig.getFreq=10136000)   <- settles, ~59 s and 4 taps later
    + * 
    + * + *

    {@code onFreqChanged} writes whatever the rig reports into {@code GeneralVariables.band}, + * which is also what the reassert heartbeat pushes back out. So a single bad reading is + * promoted from an observation into a command, and then actively fights the operator's + * selection for as long as it survives. + * + *

    The distinction this class draws is deliberately narrow, because in general a report + * the app did not ask for is indistinguishable from the operator turning the VFO by hand — + * and fighting a manual tune would be its own bug. The one case we can identify from + * evidence is a report arriving while the rig is refusing our commands: every one of those + * 29 rejections followed an {@code FA} set-frequency, and the bogus reading appeared inside + * that window. A reading taken while the command stream is known to be desynchronised is + * not trustworthy enough to command back. + */ +public final class RigDialTarget { + + private RigDialTarget() {} + + /** + * Whether a frequency the rig has just reported should become the dial the app asserts. + * + *

    Adopting is the normal case: it is how the app follows the operator turning the + * VFO. It is refused only while the rig is rejecting our commands, where the reading + * may be a mis-parse or a stale frame rather than the operator's intent. + * + * @param nowMs current wall clock + * @param rigRejectedAtMs when the rig last answered with an error / unparseable frame, + * or 0 if never + * @param reportedHz the frequency just reported + */ + /** + * How long after a rejection the rig's frequency reports stay untrusted. + * + *

    A timestamp rather than a "rejected since last command" flag, deliberately. The + * flag had to be cleared by the next outgoing command — but the CAT liveness watchdog + * polls the rig every {@code CAT_LIVENESS_TICK_MS} (3 s) with a frequency read, and + * that unrelated poll would clear the flag between the rejection and the bad report, + * defeating the guard entirely. A window depends on nothing but the clock. + * + *

    Sized to the poll interval: long enough to cover the ~800 ms between the command + * batch and the rejection plus the report that follows it, short enough that a healthy + * stream is trusted again almost immediately. + */ + public static final long DESYNC_DISTRUST_MS = 3_000; + + public static boolean shouldAdoptAsTarget(long nowMs, long rigRejectedAtMs, long reportedHz) { + if (reportedHz <= 0) return false; + if (rigRejectedAtMs <= 0) return true; + long since = nowMs - rigRejectedAtMs; + // A backwards clock correction must not silently re-trust the stream. + if (since < 0) return false; + return since >= DESYNC_DISTRUST_MS; + } + + /** + * The dial {@code setOperationBand()} should actually send. + * + *

    Falls back to the observed band when no commanded dial has been established yet + * (first run, or a config load that predates this field), so behaviour is unchanged + * until the operator or the app picks a frequency explicitly. + * + * @param commandedHz the last dial the app or operator explicitly selected, or 0 + * @param observedHz {@code GeneralVariables.band} — may have been overwritten by a + * rig report + */ + public static long dialToCommand(long commandedHz, long observedHz) { + return commandedHz > 0 ? commandedHz : observedHz; + } +} diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu39Rig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu39Rig.java index 5b3f66872..9d3421fe3 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu39Rig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu39Rig.java @@ -205,6 +205,10 @@ private void processCommand(String bufStr) { if (yaesu3Command == null) { if (bufStr.length() > 0) { fileLog("rig.parse: unknown command: " + bufStr); + // The CAT stream is desynchronised (a "?;" rejection, or a frame we could + // not parse). Frequencies reported while this holds are not trustworthy + // enough to adopt as the dial we command back — see RigDialTarget. + GeneralVariables.rigRejectedAtMs = System.currentTimeMillis(); } return; } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/FrequencyPickerSheet.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/FrequencyPickerSheet.kt index 95275c96d..7cfe82471 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/FrequencyPickerSheet.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/FrequencyPickerSheet.kt @@ -46,6 +46,9 @@ fun selectBandIndex(mainViewModel: MainViewModel, context: Context, index: Int) val oldWaveLength = BaseRigOperation.getMeterFromFreq(GeneralVariables.band) GeneralVariables.bandListIndex = index GeneralVariables.band = OperationBand.getBandFreq(index) + // An explicit operator choice: this is the dial the app asserts from now on, and the + // one the reassert heartbeat re-sends. See RigDialTarget. + GeneralVariables.commandedBandHz = GeneralVariables.band val newWaveLength = BaseRigOperation.getMeterFromFreq(GeneralVariables.band) mainViewModel.databaseOpr.writeConfig( "bandFreq", GeneralVariables.band.toString(), null, diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/RigDialTargetTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/RigDialTargetTest.java new file mode 100644 index 000000000..0506498fa --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/RigDialTargetTest.java @@ -0,0 +1,124 @@ +package com.k1af.ft8af.rigs; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Unit tests for {@link RigDialTarget} — the separation between the dial the app commands + * and the dial it merely observes. + * + *

    Reported as "it takes a loooong time for the radio to change frequencies". Measured: + * the command itself was dispatched in 815 ms, but the rig rejected it ("?;", 29 times that + * session), reported a frequency nobody had asked for, and the app then commanded that + * value back — so a 30m selection took ~59 s and four taps to stick. + */ +public class RigDialTargetTest { + + private static final long M30 = 10_136_000L; + private static final long M20 = 14_074_000L; + /** The value the rig reported that nobody selected, from the 19:54 trace. */ + private static final long BOGUS = 14_239_985L; + private static final long T0 = 1_700_000_000_000L; + + // ---- shouldAdoptAsTarget ------------------------------------------------ + + @Test + public void healthyStream_adoptsTheReportedDial() { + // The normal case, and the reason this isn't simply "never trust the rig": this is + // how the app follows the operator turning the VFO by hand. + assertThat(RigDialTarget.shouldAdoptAsTarget(T0, 0L, M20)).isTrue(); + } + + @Test + public void desyncedStream_refusesTheReportedDial() { + // The measured failure: a reading taken while the rig is rejecting our commands + // must not become something we command back at it. + assertThat(RigDialTarget.shouldAdoptAsTarget(T0, T0, BOGUS)).isFalse(); + } + + @Test + public void desyncRefusesEvenAPlausibleLookingDial() { + // The bogus value was in the same band as the real one, so plausibility is no + // defence — only the stream state distinguishes them. + assertThat(RigDialTarget.shouldAdoptAsTarget(T0, T0, M20)).isFalse(); + } + + @Test + public void zeroOrNegativeIsNeverAdopted() { + // rig.getFreq=0 shows up in the logs on a fresh connect, before any real read. + assertThat(RigDialTarget.shouldAdoptAsTarget(T0, 0L, 0)).isFalse(); + assertThat(RigDialTarget.shouldAdoptAsTarget(T0, 0L, -1)).isFalse(); + assertThat(RigDialTarget.shouldAdoptAsTarget(T0, T0, 0)).isFalse(); + } + + @Test + public void trustReturnsAfterTheDistrustWindow() { + // A window, not a "rejected since last command" flag: the CAT liveness watchdog + // polls the rig every 3 s, and that unrelated send would have cleared a flag + // between the rejection and the bad report — defeating the guard entirely. + long after = T0 + RigDialTarget.DESYNC_DISTRUST_MS; + assertThat(RigDialTarget.shouldAdoptAsTarget(after, T0, M20)).isTrue(); + assertThat(RigDialTarget.shouldAdoptAsTarget(after - 1, T0, M20)).isFalse(); + } + + @Test + public void windowOutlastsTheGapBetweenCommandAndRejection() { + // The rejection arrived ~800 ms after the command batch, and the bogus report + // followed it. Too short a window would re-trust before the bad report lands. + assertThat(RigDialTarget.DESYNC_DISTRUST_MS).isAtLeast(1_500L); + } + + @Test + public void backwardsClockDoesNotSilentlyReTrust() { + // System.currentTimeMillis() is not monotonic and this app disciplines its clock. + assertThat(RigDialTarget.shouldAdoptAsTarget(T0 - 60_000, T0, M20)).isFalse(); + } + + @Test + public void neverRejectedMeansAlwaysTrusted() { + assertThat(RigDialTarget.shouldAdoptAsTarget(T0, 0L, M20)).isTrue(); + } + + // ---- dialToCommand ------------------------------------------------------ + + @Test + public void commandsTheChosenDialNotTheObservedOne() { + // The 19:54:29 line that cost the operator the most: observed had been overwritten + // with the bogus reading, chosen was still 30m. Chosen wins. + assertThat(RigDialTarget.dialToCommand(M30, BOGUS)).isEqualTo(M30); + } + + @Test + public void fallsBackToObservedWhenNothingChosenYet() { + // First run, or a config load predating commandedBandHz — behaviour must be + // exactly as before until something is explicitly selected. + assertThat(RigDialTarget.dialToCommand(0, M20)).isEqualTo(M20); + } + + @Test + public void agreementIsUnchanged() { + assertThat(RigDialTarget.dialToCommand(M20, M20)).isEqualTo(M20); + } + + // ---- the sequence that made a band change take a minute ------------------ + + @Test + public void theMeasuredBandChangeNowHolds() { + // Operator taps 30m. + long chosen = M30; + // Rig rejects the set-frequency and reports something nobody asked for. + long observed = BOGUS; + if (RigDialTarget.shouldAdoptAsTarget(T0, T0, observed)) { + chosen = observed;// old behaviour: the bad reading became the target + } + // The reassert heartbeat fires. + assertThat(RigDialTarget.dialToCommand(chosen, observed)).isEqualTo(M30); + + // Once the stream re-synchronises and the rig confirms 30m, nothing changes. + if (RigDialTarget.shouldAdoptAsTarget(T0, 0L, M30)) { + chosen = M30; + } + assertThat(RigDialTarget.dialToCommand(chosen, M30)).isEqualTo(M30); + } +} From 736daaf1521ea20ede8e9d2f638e065787475b1f Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sat, 1 Aug 2026 15:13:44 -0500 Subject: [PATCH 101/113] Hold key-up while this slot's fast decode is still running (#712) * Hold key-up while this slot's fast decode is still running Missing callers turned out to be load-dependent, which is backwards from what an operator needs: a pileup is exactly when auto-answer matters most. Measured across one morning's activation: busy 09:15-09:45 quiet 09:45 on decodes kept per cycle 14.2 6.3 deliveries landing after key-up 35 0 The fast pass is delivered ~earlyDecodeMillis + decode time into the slot, and key-up fires ~0.45s into the next one, so the decode has under two seconds. On a busy band it does not make it, delivery slips past the boundary, and the sequencer has already committed. #709 stopped those being discarded, but a stashed decode is replayed on the NEXT cycle -- so a third of callers were still answered 15s late, which is what the operator was working around by hand. There is ~1.9s of unused headroom before the audio slack runs out. Spend it, but only when there is something to wait for: FastDecodeGate marks a fast pass in flight, and the cycle-timer callback waits for it before keying up. On a quiet band the decode has already finished and the wait returns immediately, so key-up timing is unchanged for most cycles. A fixed delay would have been the wrong shape -- it would tax every transmission to fix a load-dependent problem. That is why the earlier "hold key-up ~1.2s" option was rejected in favour of #704's mid-cycle restart; conditional on a decode actually running, the objection does not apply. The bound is the load-bearing part. keyUpHoldLimitMs reserves the configured pttDelay plus KEYUP_HOLD_RESERVE_MS for waveform generation and output setup, so a held start still begins inside the slack and clips no leading Costas array -- the defect this codebase has already shipped twice. When the reserves exceed the slack the limit floors at zero and behaviour is exactly as today. Two ordering details: the gate is marked in flight BEFORE the decode thread starts (the transmitter can reach its check first and would otherwise see an idle gate), and released after DELIVERY rather than after decoding, in a finally, since the sequencer acts inside the delivery callback. Co-Authored-By: Claude Opus 5 (1M context) * Self-review: stop a failed decode stranding the key-up gate forever fastDecodeGate.begin() runs before the decode thread starts, but end() was only in a finally around the delivery call, roughly thirty lines into run(). Anything throwing before it -- JNI decoder init, pressFloatDecode, runDecode, OOM -- left the gate in flight permanently, and every later key-up would then wait out the full hold before transmitting. The bound means it could not clip audio, but it would silently add ~1.7s to every transmission for the rest of the session. The thread body is now wrapped so end() always runs. The inner release stays: it fires right after DELIVERY so the transmitter is unblocked as early as possible rather than waiting for the deep passes, and end() is idempotent (already covered by a test). Co-Authored-By: Claude Opus 5 (1M context) * Address Copilot review on the key-up hold gate (PR #712) begin()'s Javadoc claimed the decode thread calls it, but it is called by the spawning thread on purpose -- marking in flight only once the decode thread is scheduled would let the transmitter see an idle gate and key up against a decode about to run. Documented the ownership so the next change does not "fix" it by moving the call inside the thread. end()'s doc now also mentions the finally backstop added in c8bc1cd2 and that the two release paths rely on it being idempotent. Docs plus one stray indent from c8bc1cd2; no behavior change. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../java/com/k1af/ft8af/MainViewModel.java | 5 + .../ft8af/ft8listener/FT8SignalListener.java | 39 ++++++- .../ft8af/ft8listener/FastDecodeGate.java | 101 ++++++++++++++++++ .../ft8af/ft8transmit/FT8TransmitSignal.java | 88 +++++++++++++++ .../ft8af/ft8listener/FastDecodeGateTest.java | 98 +++++++++++++++++ .../FT8TransmitSignalKeyUpHoldTest.java | 89 +++++++++++++++ 6 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FastDecodeGate.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/ft8listener/FastDecodeGateTest.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignalKeyUpHoldTest.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 1954a70cd..c1f913e08 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -1005,6 +1005,11 @@ public void doOnAfterQueryCallsignLocation(CallsignInfo callsignInfo) { }); + // Let the transmitter hold key-up while this slot's fast decode is still running, + // so a busy band answers callers in the right cycle instead of one late. + // See FastDecodeGate. + ft8TransmitSignal.setFastDecodeGate(ft8SignalListener.getFastDecodeGate()); + //create meter protection controller (ALC auto-volume + SWR halt) meterProtectionController = new MeterProtectionController(); meterProtectionController.setTransmitSignal(ft8TransmitSignal); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FT8SignalListener.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FT8SignalListener.java index afdb5acb0..310fe8067 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FT8SignalListener.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FT8SignalListener.java @@ -52,6 +52,17 @@ public class FT8SignalListener { // work never slows the next slot's early decode. See LateDecode.AnalysisGate. private static final LateDecode.AnalysisGate ANALYSIS_GATE = new LateDecode.AnalysisGate(); + /** + * Lets the transmitter hold key-up while this slot's fast pass is still running, so a + * busy band answers callers in the right cycle instead of one late. See + * {@link FastDecodeGate}. + */ + private final FastDecodeGate fastDecodeGate = new FastDecodeGate(); + + public FastDecodeGate getFastDecodeGate() { + return fastDecodeGate; + } + static { try { @@ -199,9 +210,25 @@ public void decodeFt8(long utc, float[] voiceData, ModeProfile mode, // int data[][] = reader.getData(); //---------------------------------------------------------- + // Marked in flight BEFORE the thread starts, not inside it: the transmitter can + // reach its key-up check before this thread is scheduled, and would then see an + // idle gate and key up against a decode that was about to run. + fastDecodeGate.begin(); new Thread(new Runnable() { @Override public void run() { + try { + runDecodeThread(); + } finally { + // Backstop. The timely release happens right after delivery below, but + // anything throwing before it — JNI init, a decode failure, OOM — + // would otherwise strand the gate in flight FOREVER, making every + // later key-up wait out the full hold. end() is idempotent. + fastDecodeGate.end(); + } + } + + private void runDecodeThread() { long time = System.currentTimeMillis(); if (onFt8Listen != null) { onFt8Listen.beforeListen(utc); @@ -235,8 +262,16 @@ public void run() { addMsgToList(allMsg, msgs); timeSec = System.currentTimeMillis() - time; decodeTimeSec.postValue(timeSec);// decode elapsed time - if (onFt8Listen != null) { - onFt8Listen.afterDecode(utc, averageOffset(allMsg), UtcTimer.sequential(utc), msgs, false); + try { + if (onFt8Listen != null) { + onFt8Listen.afterDecode(utc, averageOffset(allMsg), UtcTimer.sequential(utc), msgs, false); + } + } finally { + // Released only after DELIVERY, not merely after decoding: the + // sequencer acts inside afterDecode, so a transmitter waiting on this + // would otherwise still race it. finally, so a throw in a listener + // cannot strand TX waiting out the full hold. See FastDecodeGate. + fastDecodeGate.end(); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FastDecodeGate.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FastDecodeGate.java new file mode 100644 index 000000000..13422dd15 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FastDecodeGate.java @@ -0,0 +1,101 @@ +package com.k1af.ft8af.ft8listener; + +/** + * Lets the transmitter wait for the fast decode of the slot that just ended, instead of + * keying up while it is still running and acting on the result a cycle late. + * + *

    The fast pass is delivered about {@code earlyDecodeMillis} plus decode time into the + * slot, and key-up happens roughly 0.45 s into the next one — so the decode has under two + * seconds to finish. Whether it does depends entirely on how busy the band is. Measured + * across one morning's activation: + * + *

" diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java b/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java index 3c58c3b41..4fb585318 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java @@ -424,7 +424,9 @@ private String getFollowCallsigns() { HtmlContext.tableCell(result , HtmlContext.htmlEscape(cursor.getString(i)) , String.format("%s" - , HtmlContext.htmlEscape(cursor.getString(i).replace("/", "_")) + // Path segment: percent-encode, so a space/?/#/& in the + // callsign can't break the link or change the route. + , HtmlContext.urlPathSegment(cursor.getString(i).replace("/", "_")) , GeneralVariables.getStringFromResource(R.string.html_delete)) ).append("\n"); } @@ -1075,9 +1077,11 @@ private Response getMessages(IHTTPSession session) { HtmlContext.tableCell(result, String.format("" + "%s  " + "%s  %s" - , pageSize, HtmlContext.htmlEscape(callTo) + // href query values are percent-encoded (entity resolution would turn an + // escaped & back into a parameter separator); link text is HTML-escaped. + , pageSize, HtmlContext.urlQueryValue(callTo) , HtmlContext.htmlEscape(callTo) - , pageSize, HtmlContext.htmlEscape(callFrom) + , pageSize, HtmlContext.urlQueryValue(callFrom) , HtmlContext.htmlEscape(callFrom), HtmlContext.htmlEscape(extra))); HtmlContext.tableCell(result, BaseRigOperation.getFrequencyStr(band)).append("\n"); HtmlContext.tableRowEnd(result).append("\n"); @@ -1322,14 +1326,15 @@ private Response getSWLQsoMessages(IHTTPSession session) { //Generate one row of the data table HtmlContext.tableCell(result, String.format("%d", order + 1 + pageSize * (pageIndex - 1))); HtmlContext.tableCell(result, String.format("%s" - , pageSize, HtmlContext.htmlEscape(call) + , pageSize, HtmlContext.urlQueryValue(call) , HtmlContext.htmlEscape(call))); HtmlContext.tableCell(result, gridsquare == null ? "" : HtmlContext.htmlEscape(gridsquare)); - HtmlContext.tableCell(result, mode, rst_sent, rst_rcvd, qso_date, time_on, qso_date_off, time_off); - HtmlContext.tableCell(result, band, freq); + // Straight DB columns: imported ADIF can put anything in these, so escape them too. + HtmlContext.tableCellEscaped(result, mode, rst_sent, rst_rcvd, qso_date, time_on, qso_date_off, time_off); + HtmlContext.tableCellEscaped(result, band, freq); HtmlContext.tableCell(result, String.format("%s" , pageSize - , HtmlContext.htmlEscape(station_callsign) + , HtmlContext.urlQueryValue(station_callsign) , HtmlContext.htmlEscape(station_callsign))); HtmlContext.tableCell(result, my_gridsquare == null ? "" : HtmlContext.htmlEscape(my_gridsquare)); @@ -1637,13 +1642,14 @@ private Response getQsoLogs(IHTTPSession session) { , GeneralVariables.getStringFromResource(R.string.html_qso_raw)));//Whether it was imported HtmlContext.tableCell(result, String.format("%s" , pageSize - , HtmlContext.htmlEscape(call) + , HtmlContext.urlQueryValue(call) , HtmlContext.htmlEscape(call))); - HtmlContext.tableCell(result, gridsquare == null ? "" : HtmlContext.htmlEscape(gridsquare), mode, rst_sent, rst_rcvd + // Straight DB columns: imported ADIF can put anything in these, so escape them too. + HtmlContext.tableCellEscaped(result, gridsquare == null ? "" : gridsquare, mode, rst_sent, rst_rcvd , qso_date, time_on, qso_date_off, time_off, band, freq); HtmlContext.tableCell(result, String.format("%s" , pageSize - , HtmlContext.htmlEscape(station_callsign) + , HtmlContext.urlQueryValue(station_callsign) , HtmlContext.htmlEscape(station_callsign))); HtmlContext.tableCell(result, my_gridsquare == null ? "" : HtmlContext.htmlEscape(my_gridsquare) , HtmlContext.htmlEscape(comment)).append("\n"); diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/html/HtmlEscapeTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/html/HtmlEscapeTest.java index 136db2ab3..fbdb4187a 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/html/HtmlEscapeTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/html/HtmlEscapeTest.java @@ -82,4 +82,77 @@ public void mixedContentEscapedInPlace() { assertThat(HtmlContext.htmlEscape("a & b < c > d \" e ' f")) .isEqualTo("a & b < c > d " e ' f"); } + + // ---- URL encoding ------------------------------------------------------- + // HTML escaping alone is wrong inside an href: the browser resolves entities + // BEFORE parsing the URL, so an escaped & becomes a real & and splits the + // query string. These lock the percent-encoding that prevents that. + + @Test + public void queryValue_percentEncodesParameterSeparators() { + // Parameter pollution: a callsign of A&pageSize=9999 must stay one value. + String encoded = HtmlContext.urlQueryValue("A&pageSize=9999"); + assertThat(encoded).doesNotContain("&"); + assertThat(encoded).doesNotContain("="); + assertThat(encoded).isEqualTo("A%26pageSize%3D9999"); + } + + @Test + public void queryValue_percentEncodesFragmentAndSpace() { + assertThat(HtmlContext.urlQueryValue("K1 ABC#frag")).isEqualTo("K1+ABC%23frag"); + } + + @Test + public void queryValue_leavesNoMarkupSignificantCharacters() { + String encoded = HtmlContext.urlQueryValue("\">"); + assertThat(encoded).doesNotContain("\""); + assertThat(encoded).doesNotContain("<"); + assertThat(encoded).doesNotContain(">"); + assertThat(encoded).doesNotContain("'"); + } + + @Test + public void queryValue_nullIsEmpty() { + assertThat(HtmlContext.urlQueryValue(null)).isEmpty(); + } + + @Test + public void pathSegment_encodesSpaceAsPercent20NotPlus() { + // In a path segment '+' is a literal plus, so the form encoder's '+' is wrong. + assertThat(HtmlContext.urlPathSegment("K1 ABC")).isEqualTo("K1%20ABC"); + } + + @Test + public void pathSegment_cannotEscapeItsOwnSegment() { + // A '/' must not let the value reach a different route, and '?'/'#' must not + // start a query or fragment. + String encoded = HtmlContext.urlPathSegment("../admin?x=1#y"); + assertThat(encoded).doesNotContain("/"); + assertThat(encoded).doesNotContain("?"); + assertThat(encoded).doesNotContain("#"); + } + + @Test + public void pathSegment_nullIsEmpty() { + assertThat(HtmlContext.urlPathSegment(null)).isEmpty(); + } + + // ---- tableCellEscaped --------------------------------------------------- + + @Test + public void tableCellEscaped_escapesEveryValue() { + // Straight DB columns (mode/RST/date/band/freq) reach the page through this; + // imported ADIF can carry markup in any of them. + StringBuilder sb = new StringBuilder(); + HtmlContext.tableCellEscaped(sb, "FT8", ""); + assertThat(sb.toString()) + .isEqualTo("FT8<script>alert(1)</script>
"); - for (int i = 0; i < GeneralVariables.followCallsign.size(); i++) { - result.append(GeneralVariables.followCallsign.get(i)); - result.append(", "); - if (((i + 1) % 10) == 0) { - result.append("
\n"); - } - } + result.append(followCallsignBlock(GeneralVariables.followCallsign)); result.append("
\n"); + } + index++; + } + return result.toString(); + } + /** * Show callsign hash table * diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ClearCacheDataDialog.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ClearCacheDataDialog.java index 81981dce9..b5d901469 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ClearCacheDataDialog.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ClearCacheDataDialog.java @@ -105,8 +105,10 @@ public void onClick(View view) { StringBuilder msg = new StringBuilder(); if (cache_mode == CACHE_MODE.FOLLOW_DATA) { msg.append(GeneralVariables.getStringFromResource(R.string.html_tracking_callsign)); - for (int i = 0; i < GeneralVariables.followCallsign.size(); i++) { - msg.append("\n" + GeneralVariables.followCallsign.get(i)); + // for-each snapshots the CopyOnWriteArrayList; a size()+get(i) scan + // could race a concurrent add/clear into IndexOutOfBounds. + for (String callsign : GeneralVariables.followCallsign) { + msg.append("\n" + callsign); } cacheHelpMessage.setText(msg.toString()); } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/GeneralVariablesFollowCallsignTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/GeneralVariablesFollowCallsignTest.java new file mode 100644 index 000000000..3ae679dc0 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/GeneralVariablesFollowCallsignTest.java @@ -0,0 +1,89 @@ +package com.k1af.ft8af; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** + * Pins the thread-safety of {@link GeneralVariables#followCallsign}. + * + *

The list is mutated with no external lock from several threads: the + * decode/DB threads add to it ({@code MainViewModel.addFollowCallsign}, + * {@code getFollowCallsignsFromDataBase}) and the UI thread clears it + * ({@code ClearCacheDataDialog}), while the web logbook renders it on a + * NanoHTTPD worker thread ({@code LogHttpServer}). As a plain {@code ArrayList} + * that contention corrupts the backing array and a {@code size()}+{@code get(i)} + * scan on the HTTP thread races a concurrent clear into an + * {@link IndexOutOfBoundsException}. It must be a {@link CopyOnWriteArrayList} + * (mirroring its sibling {@code transmitMessages}) so every add/clear is atomic + * and for-each readers get a stable snapshot. + */ +@RunWith(RobolectricTestRunner.class) +public class GeneralVariablesFollowCallsignTest { + + @Before + public void setUp() { + GeneralVariables.followCallsign.clear(); + } + + @After + public void tearDown() { + GeneralVariables.followCallsign.clear(); + } + + @Test + public void followCallsign_isCopyOnWriteArrayList() { + // Regression lock: a plain ArrayList reintroduces the cross-thread + // web-logbook crash this guards against. + assertThat(GeneralVariables.followCallsign).isInstanceOf(CopyOnWriteArrayList.class); + } + + @Test + public void concurrentAddClear_duringForEach_neverThrows() throws Exception { + for (int i = 0; i < 32; i++) { + GeneralVariables.followCallsign.add("SEED" + i); + } + final AtomicReference failure = new AtomicReference<>(); + final int rounds = 5000; + + Thread writer = new Thread(() -> { + try { + for (int r = 0; r < rounds; r++) { + GeneralVariables.followCallsign.add("W" + (r % 64)); + if ((r % 25) == 0) { + GeneralVariables.followCallsign.clear(); + } + } + } catch (Throwable t) { + // Record writer-side failures too; a bare thread throw would be + // swallowed by the JVM and let this test pass despite a crash. + failure.compareAndSet(null, t); + } + }); + writer.start(); + + try { + for (int r = 0; r < rounds; r++) { + // The read pattern LogHttpServer/ClearCacheDataDialog now use: + // a for-each over the shared list must never throw while the + // writer thread adds/clears it. + StringBuilder sink = new StringBuilder(); + for (String callsign : GeneralVariables.followCallsign) { + sink.append(callsign); + } + } + } catch (Throwable t) { + failure.compareAndSet(null, t); + } + writer.join(); + + assertThat(failure.get()).isNull(); + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/html/LogHttpServerFollowBlockTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/html/LogHttpServerFollowBlockTest.java new file mode 100644 index 000000000..37e6634a3 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/html/LogHttpServerFollowBlockTest.java @@ -0,0 +1,144 @@ +package com.k1af.ft8af.html; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.Test; + +/** + * Pure-logic coverage for {@link LogHttpServer#followCallsignBlock(List)}, the + * followed-callsign cell renderer on the web-logbook config page. + * + *

The block used to be an inline {@code for (i < followCallsign.size()) + * followCallsign.get(i)} index scan run on a NanoHTTPD worker thread, while the + * decode/DB threads add to and the UI thread clears the shared + * {@code GeneralVariables.followCallsign} list. That {@code size()}-then-{@code + * get(i)} pattern races a concurrent clear straight into an + * {@link IndexOutOfBoundsException}, and a plain {@code ArrayList} has no + * happens-before against the writers at all. Extracting the loop and iterating + * with a for-each lets a {@link CopyOnWriteArrayList} hand back a stable + * snapshot. These tests pin the exact HTML (including the ten-per-row break) and + * lock in that a concurrent add/clear during rendering never throws. No Android + * types are touched, so no Robolectric runner is needed. + */ +public class LogHttpServerFollowBlockTest { + + @Test + public void emptyList_rendersNothing() { + assertThat(LogHttpServer.followCallsignBlock(new ArrayList<>())).isEmpty(); + } + + @Test + public void singleCallsign_rendersOneCell() { + List calls = new ArrayList<>(); + calls.add("K1ABC"); + assertThat(LogHttpServer.followCallsignBlock(calls)).isEqualTo("K1ABC, "); + } + + @Test + public void tenCallsigns_breakRowAfterTheTenth() { + List calls = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + calls.add("C" + i); + } + String html = LogHttpServer.followCallsignBlock(calls); + // The row break fires once, immediately after the tenth cell. + assertThat(html).endsWith(", 

\n"); + assertThat(countOccurrences(html, "
\n")).isEqualTo(1); + } + + @Test + public void elevenCallsigns_startNewRowForTheEleventh() { + List calls = new ArrayList<>(); + for (int i = 0; i < 11; i++) { + calls.add("C" + i); + } + String html = LogHttpServer.followCallsignBlock(calls); + // Exactly one break (after ten), and the eleventh trails it. + assertThat(countOccurrences(html, "
\n")).isEqualTo(1); + assertThat(html).endsWith("C10, "); + } + + @Test + public void twentyCallsigns_breakTwice() { + List calls = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + calls.add("C" + i); + } + String html = LogHttpServer.followCallsignBlock(calls); + assertThat(countOccurrences(html, "
\n")).isEqualTo(2); + } + + @Test + public void preservesInsertionOrder() { + List calls = new ArrayList<>(); + calls.add("W1AW"); + calls.add("VE3XYZ"); + calls.add("G0ABC"); + assertThat(LogHttpServer.followCallsignBlock(calls)) + .isEqualTo("W1AW, VE3XYZ, G0ABC, "); + } + + /** + * The reason the field is a CopyOnWriteArrayList: a writer thread adding and + * clearing it while the renderer iterates must never throw. A plain + * ArrayList would surface a ConcurrentModificationException / a torn read; + * the COW snapshot iterator is immune. Run enough rounds that a plain-list + * implementation would reliably trip. + */ + @Test + public void concurrentAddClear_duringRender_neverThrows() throws Exception { + final List shared = new CopyOnWriteArrayList<>(); + for (int i = 0; i < 50; i++) { + shared.add("SEED" + i); + } + final AtomicReference failure = new AtomicReference<>(); + final int rounds = 5000; + + Thread writer = new Thread(() -> { + try { + for (int r = 0; r < rounds; r++) { + shared.add("X" + (r % 64)); + if ((r % 32) == 0) { + shared.clear(); + } + } + } catch (Throwable t) { + // Record writer-side failures too; a bare thread throw would be + // swallowed by the JVM and let this test pass despite a crash. + failure.compareAndSet(null, t); + } + }); + writer.start(); + + try { + for (int r = 0; r < rounds; r++) { + // Must not throw even as the list is mutated underneath us. + LogHttpServer.followCallsignBlock(shared); + } + } catch (Throwable t) { + failure.compareAndSet(null, t); + } + writer.join(); + + assertThat(failure.get()).isNull(); + } + + private static int countOccurrences(String haystack, String needle) { + int count = 0; + int from = 0; + while (true) { + int idx = haystack.indexOf(needle, from); + if (idx < 0) { + break; + } + count++; + from = idx + needle.length(); + } + return count; + } +} From c740c8ae020bfbbe00ca810162941d9bbe43ed04 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 16:19:19 -0500 Subject: [PATCH 064/113] Fix FT-2000 CAT: SWR meter dead during TX (only first of two coalesced replies parsed) (#667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yaesu38Rig ("YAESU FT-2000 series") polls meters by sending two reads back-to-back — RM4; (ALC) then RM6; (SWR). The transport routinely delivers both replies coalesced in a single onReceiveData chunk ("RM4nnn;RM6nnn;"). The old parser consumed only the first ';'-terminated command (ALC) and re-buffered the rest *with* its terminator; the next meter poll's clearBufferData() then wiped that carry-over, so the SWR (second) reply was permanently dropped. Result: the SWR meter never moved during TX and the high-SWR safety alert never fired. Fix: onReceiveData now drains EVERY complete ';'-terminated command from the accumulated buffer and carries only the unterminated tail into the next read. The framing is extracted to a pure, package-private static helper (splitCommands + Frames) so it is unit-testable without the rig's Timer-scheduling constructor; per-command handling moves to processCommand. Also collapses a latent double getFrequency() call. This is the same root cause fixed for the FT-891 in #665, on a distinct still-affected rig. Kept self-contained (no shared splitter class) to avoid colliding with #665's in-flight CatLineSplitter; the identical Yaesu gen-3 siblings (FTDX-450/DX10/Wolf-SDR) remain follow-ups. Tests: Yaesu38RigTest — 8 pure-JVM cases (coalesced pair, split-across- reads carry, partial tail, leading terminator, empty). Full :app:testDebugUnitTest green. Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/rigs/Yaesu38Rig.java | 112 ++++++++++++------ .../com/k1af/ft8af/rigs/Yaesu38RigTest.java | 100 ++++++++++++++++ 2 files changed, 174 insertions(+), 38 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu38RigTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu38Rig.java b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu38Rig.java index be8abd85d..84e0fef12 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu38Rig.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/rigs/Yaesu38Rig.java @@ -10,6 +10,8 @@ import com.k1af.ft8af.database.ControlMode; import com.k1af.ft8af.ui.ToastMessage; +import java.util.ArrayList; +import java.util.List; import java.util.Timer; import java.util.TimerTask; @@ -130,51 +132,85 @@ public void setFreqToRig() { @Override public void onReceiveData(byte[] data) { - String s = new String(data); - if (!s.contains(";")) { - buffer.append(s); - if (buffer.length() > 1000) clearBufferData(); - return;//data reception not yet complete. - } else { - if (s.indexOf(";") > 0) {//received end-of-data, and delimiter is not the first character - buffer.append(s.substring(0, s.indexOf(";"))); - } - - //begin parsing data - Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(buffer.toString()); - clearBufferData();//clear buffer - //put remaining data into buffer - buffer.append(s.substring(s.indexOf(";") + 1)); + buffer.append(new String(data)); + + // Drain EVERY complete ';'-terminated command, keeping only the + // unterminated tail. The meter poll sends two reads back-to-back + // (RM4; ALC then RM6; SWR), so the transport frequently coalesces both + // replies into one chunk ("RM4nnn;RM6nnn;"). The old parser handled only + // the first command and re-buffered the rest with its terminator, where + // the next poll's clearBufferData() wiped it — permanently losing the + // SWR reading. See splitCommands / processCommand. + Frames frames = splitCommands(buffer.toString()); + clearBufferData(); + buffer.append(frames.remainder); + // Guard against unbounded growth if a terminator never arrives. + if (buffer.length() > 1000) clearBufferData(); + + for (String command : frames.commands) { + processCommand(command); + } + } - if (yaesu3Command == null) { - return; + /** + * Parse and act on a single ';'-terminated command (terminator stripped). + * Extracted so {@link #onReceiveData} stays a thin drain loop. + */ + private void processCommand(String command) { + Yaesu3Command yaesu3Command = Yaesu3Command.getCommand(command); + if (yaesu3Command == null) { + return; + } + if (yaesu3Command.getCommandID().equalsIgnoreCase("FA") + || yaesu3Command.getCommandID().equalsIgnoreCase("FB")) { + long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); + if (tempFreq != 0) {//if tempFreq==0, frequency is invalid + setFreq(tempFreq); } - //long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); - //if (tempFreq != 0) {//if tempFreq==0, frequency is invalid - // setFreq(Yaesu3Command.getFrequency(yaesu3Command)); - //} - - - if (yaesu3Command.getCommandID().equalsIgnoreCase("FA") - || yaesu3Command.getCommandID().equalsIgnoreCase("FB")) { - long tempFreq = Yaesu3Command.getFrequency(yaesu3Command); - if (tempFreq != 0) {//if tempFreq==0, frequency is invalid - setFreq(Yaesu3Command.getFrequency(yaesu3Command)); - } - } else if (yaesu3Command.getCommandID().equalsIgnoreCase("RM")) {//METER - if (Yaesu3Command.isSWRMeter38(yaesu3Command)) { - swr = Yaesu3Command.getALCOrSWR38(yaesu3Command); - } - if (Yaesu3Command.isALCMeter38(yaesu3Command)) { - alc = Yaesu3Command.getALCOrSWR38(yaesu3Command); - } - showAlert(); - notifyMeterData(alc, swr); + } else if (yaesu3Command.getCommandID().equalsIgnoreCase("RM")) {//METER + if (Yaesu3Command.isSWRMeter38(yaesu3Command)) { + swr = Yaesu3Command.getALCOrSWR38(yaesu3Command); } + if (Yaesu3Command.isALCMeter38(yaesu3Command)) { + alc = Yaesu3Command.getALCOrSWR38(yaesu3Command); + } + showAlert(); + notifyMeterData(alc, swr); + } + } + /** Complete commands (terminator stripped) plus the trailing unterminated tail. */ + static final class Frames { + /** Every ';'-terminated command in arrival order, terminator removed. */ + final List commands; + /** Text after the last ';' — an incomplete command to carry over. */ + final String remainder; + Frames(List commands, String remainder) { + this.commands = commands; + this.remainder = remainder; } + } + /** + * Split accumulated CAT text into complete {@code ';'}-terminated commands + * and the trailing unterminated remainder. + * + *

Pure logic — no rig or Android state — so it is unit-testable without + * the Timer-scheduling constructor. + * + * @param accumulated buffered text so far (never null; may be empty) + * @return the complete commands and the bytes to carry into the next read + */ + static Frames splitCommands(String accumulated) { + List commands = new ArrayList<>(); + int start = 0; + int semi; + while ((semi = accumulated.indexOf(';', start)) >= 0) { + commands.add(accumulated.substring(start, semi)); + start = semi + 1; + } + return new Frames(commands, accumulated.substring(start)); } @Override diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu38RigTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu38RigTest.java new file mode 100644 index 000000000..eef3bb9c0 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/Yaesu38RigTest.java @@ -0,0 +1,100 @@ +package com.k1af.ft8af.rigs; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Pure-logic coverage for {@link Yaesu38Rig#splitCommands}, the framing helper + * that drains a Yaesu gen-3 ({@code ';'}-terminated) CAT text stream into whole + * commands plus a trailing remainder. + * + *

The FT-2000 meter poll sends two reads back-to-back — {@code RM4;} (ALC) + * then {@code RM6;} (SWR) — so the transport very often delivers both replies + * coalesced in a single {@code onReceiveData} chunk ({@code "RM4nnn;RM6nnn;"}). + * The old parser consumed only the first {@code ';'}-terminated command and + * re-buffered the rest with its terminator, where the next poll's + * {@code clearBufferData()} wiped it — so the SWR (second) reply was + * permanently lost and the SWR meter never moved. These tests pin the drain + * contract: every complete command is returned, and only the unterminated tail + * is carried into the next read. + * + *

No Android types are touched, so no Robolectric runner is needed. + */ +public class Yaesu38RigTest { + + @Test + public void singleCompleteCommand_oneCommandNoRemainder() { + Yaesu38Rig.Frames f = Yaesu38Rig.splitCommands("FA007074000;"); + + assertThat(f.commands).containsExactly("FA007074000"); + assertThat(f.remainder).isEmpty(); + } + + @Test + public void twoCoalescedMeterReplies_bothCommandsReturned() { + // The regression: ALC then SWR arrive coalesced in one read. The old + // parser returned only the first (ALC); SWR was dropped. Both must be + // drained so the SWR meter updates. + Yaesu38Rig.Frames f = Yaesu38Rig.splitCommands("RM4001;RM6002;"); + + assertThat(f.commands).containsExactly("RM4001", "RM6002").inOrder(); + assertThat(f.remainder).isEmpty(); + } + + @Test + public void completeCommandPlusPartialTail_tailBecomesRemainder() { + Yaesu38Rig.Frames f = Yaesu38Rig.splitCommands("RM4001;RM6"); + + assertThat(f.commands).containsExactly("RM4001"); + assertThat(f.remainder).isEqualTo("RM6"); + } + + @Test + public void noTerminator_wholeInputIsRemainder() { + Yaesu38Rig.Frames f = Yaesu38Rig.splitCommands("FA0070"); + + assertThat(f.commands).isEmpty(); + assertThat(f.remainder).isEqualTo("FA0070"); + } + + @Test + public void emptyInput_noCommandsNoRemainder() { + Yaesu38Rig.Frames f = Yaesu38Rig.splitCommands(""); + + assertThat(f.commands).isEmpty(); + assertThat(f.remainder).isEmpty(); + } + + @Test + public void leadingTerminator_yieldsEmptyLeadingCommand() { + // A stray leading ';' (e.g. the tail terminator of a prior frame) must + // not swallow the command that follows it. + Yaesu38Rig.Frames f = Yaesu38Rig.splitCommands(";RM6002;"); + + assertThat(f.commands).containsExactly("", "RM6002").inOrder(); + assertThat(f.remainder).isEmpty(); + } + + @Test + public void splitAcrossReads_reassemblesViaRemainderCarry() { + // A command split over two reads: the first read has no terminator, so + // its bytes become the remainder that the caller re-feeds with the next + // read. Draining the concatenation recovers the whole command. + Yaesu38Rig.Frames first = Yaesu38Rig.splitCommands("FA0070"); + assertThat(first.commands).isEmpty(); + + Yaesu38Rig.Frames second = Yaesu38Rig.splitCommands(first.remainder + "74000;"); + assertThat(second.commands).containsExactly("FA007074000"); + assertThat(second.remainder).isEmpty(); + } + + @Test + public void threeCommandsPlusTail_allDrainedTailCarried() { + Yaesu38Rig.Frames f = Yaesu38Rig.splitCommands("RM4001;RM6002;FA007074000;RM4"); + + assertThat(f.commands) + .containsExactly("RM4001", "RM6002", "FA007074000").inOrder(); + assertThat(f.remainder).isEqualTo("RM4"); + } +} From 49c9bb2f830fc1d6164883f10e9b743b0b565f46 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Thu, 23 Jul 2026 16:20:28 -0500 Subject: [PATCH 065/113] Fix web-logbook crash from racing the shared QSL callsign list off-thread (#661) * Fix web-logbook crash from racing the shared QSL callsign list off-thread The debug page of the web logbook (LogHttpServer.showDebug, auto-refreshing every 5 s) rendered the "successfully contacted callsigns" block by index- looping the shared static GeneralVariables.QSL_Callsign_list with `for (i < list.size()) list.get(i)`, re-reading the static field on every size()/get(i) call, on a NanoHTTPD worker thread holding no lock. That list is a process-global mutated from other threads: * DatabaseOpr.GetAllQSLCallsign.get() rebuilds it wholesale and ref-swaps the field on a background DB thread (band change / log reload), and * FT8TransmitSignal appends to it in place on the TX thread after a QSO. A wholesale swap to a shorter list landing between the reader's size() and get(i) handed get(i) an index past the end of the new list -> mid-render IndexOutOfBoundsException. An in-place add() during the loop is likewise an unsynchronized structural mutation of the list being iterated. Root cause: an unsynchronized shared collection read on the web worker while writer threads swap and mutate it. Fix (mirrors the followCallsign / hashList off-thread fixes, PRs #659/#656): * Publish QSL_Callsign_list as a `volatile List` holding a CopyOnWriteArrayList. The volatile makes the ref-swap publish safely; copy-on-write keeps every concurrent reader (the web worker plus the decode/UI checkQSLCallsign hot path) on a stable snapshot, so an in-place add() can no longer tear an iteration. DatabaseOpr publishes a fresh CopyOnWriteArrayList so the swap stays atomic. * Extract the render into a pure, testable LogHttpServer.successfulCallsignBlock(List) that reads the field exactly once (the caller passes the reference in) and iterates that one snapshot, eliminating the size()/get() double-read of the static field. HTML output is byte-for-byte unchanged. add() runs once per completed QSO, so the copy-on-write cost is negligible; checkQSLCallsign() stays an O(n) contains() with no added cost. Tests: * LogHttpServerSuccessfulCallsignBlockTest (pure JVM) pins the exact HTML (empty list, separator, ten-per-row wrap) and stress-renders the block while a writer thread concurrently adds to the same CopyOnWriteArrayList, asserting it never throws and always emits well-formed markup. * GetAllQSLCallsignModeTest.loadPublishesCopyOnWriteList drives the real DB reload and asserts the published list is a CopyOnWriteArrayList (fail-before-fix: it was a plain ArrayList). Co-Authored-By: Claude Opus 4.8 (1M context) * Address review: for-each in successfulCallsignBlock; bound the stress writer - successfulCallsignBlock now iterates with a for-each (CopyOnWriteArrayList's snapshot iterator) instead of size()/get(i), so a concurrent clear()/remove() or a different List impl can't reintroduce a size/get TOCTOU IndexOutOfBoundsException. Output is unchanged. - The concurrentInPlaceAdd stress test now bounds the writer to 5000 adds and yields between them: CopyOnWriteArrayList copies its whole backing array per add(), so the old unbounded tight loop could balloon memory/time and go flaky. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/GeneralVariables.java | 9 +- .../com/k1af/ft8af/database/DatabaseOpr.java | 4 +- .../com/k1af/ft8af/html/LogHttpServer.java | 44 ++++-- .../database/GetAllQSLCallsignModeTest.java | 12 ++ ...HttpServerSuccessfulCallsignBlockTest.java | 127 ++++++++++++++++++ 5 files changed, 185 insertions(+), 11 deletions(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/html/LogHttpServerSuccessfulCallsignBlockTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 9fe66dcf8..ca08879f0 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -519,7 +519,14 @@ public static String excludedBandsToCsv() { //Posted each time a GPS fix disciplines the clock, so the Time Sync screen can recompose //its "last sync"/offset readout. Carries the sync's System.currentTimeMillis() timestamp. public static MutableLiveData mutableGpsClockSync = new MutableLiveData<>(); - public static ArrayList QSL_Callsign_list = new ArrayList<>();//Successfully QSL'd callsigns + // Successfully QSL'd callsigns (current band). Rebuilt wholesale on the DB thread + // (DatabaseOpr.GetAllQSLCallsign), appended to on the TX thread (addQSLCallsign), and + // read concurrently from decode/UI threads and the NanoHTTPD web-logbook worker. It is + // therefore a CopyOnWriteArrayList behind a volatile reference: the volatile makes the + // wholesale ref-swap publish safely, and copy-on-write keeps every concurrent reader on a + // stable snapshot so an in-place add() can't tear an iteration. Always assign a + // CopyOnWriteArrayList in production; see LogHttpServer.successfulCallsignBlock. + public static volatile List QSL_Callsign_list = new CopyOnWriteArrayList<>(); public static ArrayList QSL_Callsign_list_other_band = new ArrayList<>();//Successfully QSL'd callsigns on other bands public static HashSet QSL_Callsign_list_today = new HashSet<>();//Callsigns worked today or yesterday (any band); a set for O(1) membership checks public static HashSet QSL_Grid_list = new HashSet<>();//Distinct worked 4-char Maidenhead grids (any band) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index f90323651..b22e360c6 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2370,7 +2370,9 @@ public static void get(SQLiteDatabase db) { } finally { cursor.close(); } - GeneralVariables.QSL_Callsign_list = callsigns; + // Publish as a CopyOnWriteArrayList so the wholesale swap is atomic for the + // decode/UI/web-logbook readers racing this background reload (see the field's note). + GeneralVariables.QSL_Callsign_list = new java.util.concurrent.CopyOnWriteArrayList<>(callsigns); querySQL = "select distinct [call] from QSLTable where band<>?" + modeFilter.sqlSuffix; cursor = db.rawQuery(querySQL, modeFilter.withArgs(meter)); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java b/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java index 4fab136da..8c6270fbd 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/html/LogHttpServer.java @@ -32,6 +32,7 @@ import java.util.ArrayList; import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -127,6 +128,39 @@ static int normalizePageSize(int pageSize) { return pageSize > 0 ? pageSize : 100; } + /** + * Render the "successfully contacted callsigns" cell block for the debug page, ten + * callsigns per table row. + * + *

Extracted so the caller reads the shared + * {@link GeneralVariables#QSL_Callsign_list} exactly once — passing the reference in as + * {@code callsigns} — and iterates that single snapshot. The old inline loop re-read the + * static field on every {@code size()} and {@code get(i)}, so a background DB reload + * ({@link com.k1af.ft8af.database.DatabaseOpr.GetAllQSLCallsign}) that swaps the list + * wholesale between the two reads could hand {@code get(i)} an index past the end of a + * now-shorter list and throw {@link IndexOutOfBoundsException} mid-render (the page + * auto-refreshes every 5 s, so the window is hit often). With the list published as a + * CopyOnWriteArrayList a concurrent in-place add() can't tear this iteration either. + */ + static String successfulCallsignBlock(List callsigns) { + StringBuilder sb = new StringBuilder(); + sb.append("

"); + // for-each, not size()/get(i): this uses CopyOnWriteArrayList's snapshot + // iterator, so a concurrent clear()/remove() (or a different List impl + // passed in) can't reintroduce a size/get TOCTOU IndexOutOfBoundsException. + int i = 0; + for (String callsign : callsigns) { + sb.append(callsign); + sb.append(", "); + if (((i + 1) % 10) == 0) { + sb.append("
\n"); + } + i++; + } + sb.append("
"); - for (int i = 0; i < GeneralVariables.QSL_Callsign_list.size(); i++) { - result.append(GeneralVariables.QSL_Callsign_list.get(i)); - result.append(", "); - if (((i + 1) % 10) == 0) { - result.append("
\n"); - } - } - result.append("
K1ABC, 
\n")).isEqualTo(1); + assertThat(html).contains("C10, "); + assertThat(html).endsWith("
\n
\n")).isEqualTo(2); + } + + /** + * The regression: a writer mutating the same CopyOnWriteArrayList while the block renders it + * must never throw and must always produce well-formed HTML. Run against a plain ArrayList + * this loop reliably throws; against the copy-on-write list production now uses, it can't. + */ + @Test + public void concurrentInPlaceAdd_neverThrows() throws InterruptedException { + List shared = new CopyOnWriteArrayList<>(); + for (int i = 0; i < 500; i++) { + shared.add("SEED" + i); + } + + AtomicBoolean stop = new AtomicBoolean(false); + AtomicReference failure = new AtomicReference<>(); + + Thread writer = new Thread(() -> { + // Bound the writes and yield between them: CopyOnWriteArrayList copies + // its entire backing array on every add(), so an unbounded tight add + // loop balloons memory (and render time, since each render walks the + // whole list) and makes the test slow/flaky. A few thousand bounded + // adds still overlap the reader's 2000 renders and exercise concurrent + // mutation. + int n = 0; + while (!stop.get() && n < 5000) { + shared.add("NEW" + (n++)); + Thread.yield(); + } + }); + writer.start(); + + try { + for (int r = 0; r < 2000; r++) { + String html = LogHttpServer.successfulCallsignBlock(shared); + // Well-formed: opens and closes the wrapping cell every time. + assertThat(html).startsWith("
"); + assertThat(html).endsWith("
+ * + * + * + *
busy (09:15-09:45)quiet (09:45 on)
decodes kept per cycle14.26.3
deliveries landing after key-up350
+ * + *

That is backwards from what an operator needs: the busier the band, the more likely + * the app is to miss the station calling them. A pileup is precisely when auto-answer + * matters most. Those late deliveries are no longer lost — they are stashed and replayed — + * but a replay acts on the NEXT cycle, so a third of callers were answered 15 s late. + * + *

There is roughly 1.9 s of unused headroom before the audio slack runs out, so the fix + * is to spend it only when there is something to wait for. On a quiet band the decode has + * already finished and {@link #awaitIdle} returns immediately, leaving key-up timing + * untouched. On a busy band the transmitter waits the few hundred milliseconds the decode + * still needs and then sends the RIGHT message, in the right cycle. + * + *

A fixed delay would have been the wrong shape — it would tax every transmission to fix + * a load-dependent problem. This is conditional, so it costs nothing when idle. + */ +public final class FastDecodeGate { + + private final Object lock = new Object(); + private boolean inFlight; + + /** + * Marks a fast (non-deep) pass as pending. Called by whichever thread is about to + * SPAWN the decode thread — deliberately not by the decode thread itself, which may + * not be scheduled before the transmitter reaches its key-up check and would leave it + * looking at an idle gate. See the comment at the {@code begin()} call site in + * {@code FT8SignalListener.decodeFt8}. + */ + public void begin() { + synchronized (lock) { + inFlight = true; + } + } + + /** + * Called by the decode thread once the fast pass has been DELIVERED — not merely + * decoded. Waiting only until decode finished would still race the sequencer, which + * acts inside the delivery callback. + * + *

Also called from a finally around the whole decode thread body, so a throw before + * delivery cannot strand the gate in flight. Idempotent, so the two paths can both fire. + */ + public void end() { + synchronized (lock) { + inFlight = false; + lock.notifyAll(); + } + } + + /** Whether a fast pass is currently running. */ + public boolean inFlight() { + synchronized (lock) { + return inFlight; + } + } + + /** + * Block until no fast pass is in flight, or {@code deadlineEpochMs} passes. + * + *

Returns immediately when nothing is running, which is the common case on a quiet + * band — so this must stay cheap. + * + * @param deadlineEpochMs absolute wall-clock instant to give up at; see + * {@code FT8TransmitSignal.keyUpHoldDeadline} + * @param nowMs current wall clock + * @return true if the decode finished (or was never running) before the deadline + */ + public boolean awaitIdle(long deadlineEpochMs, long nowMs) { + synchronized (lock) { + long remaining = deadlineEpochMs - nowMs; + while (inFlight && remaining > 0) { + try { + lock.wait(remaining); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return !inFlight; + } + // Recompute from the clock rather than trusting a single wake: wait() can + // return spuriously, and notifyAll() from end() must not be mistaken for + // the deadline having arrived. + remaining = deadlineEpochMs - System.currentTimeMillis(); + } + return !inFlight; + } + } +} diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java index be900db06..3c33f91db 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java @@ -24,6 +24,7 @@ import com.k1af.ft8af.FT8Common; import com.k1af.ft8af.ModeProfile; +import com.k1af.ft8af.ft8listener.FastDecodeGate; import com.k1af.ft8af.Ft8Message; import com.k1af.ft8af.ft8signal.FT8Package; import com.k1af.ft8af.GeneralVariables; @@ -281,6 +282,13 @@ public void doOnSecTimer(long utc) { ToastMessage.show(GeneralVariables.getStringFromResource(R.string.callsign_error)); return; } + // Wait for this slot's fast decode if it is still running. Costs + // nothing when it has already finished (the quiet-band case, which is + // most cycles); on a busy band it spends part of the otherwise-idle + // audio slack so the sequencer keys up with the reply it is about to + // learn about, rather than answering that caller a cycle late. Bounded + // so a held start still clips no leading audio. See FastDecodeGate. + holdForFastDecode(utc); doTransmit();// transmit action follows precise timing; delay is the audio signal delay } } @@ -366,6 +374,86 @@ static ManualTxGate decideManualTx(long msInCycle, int audioSlackMillis, */ static final int RESTART_HEADROOM_MS = 250; + /** + * Work that still has to happen after key-up is released and before audio starts: + * the PTT round trip, waveform generation, and opening the output path. Reserved out + * of the audio slack so a hold can never eat the margin that keeps the leading Costas + * sync array intact — the one failure this codebase must never reintroduce. + * + *

{@code pttDelay} is subtracted separately because it is user-configurable; this + * covers the rest. + */ + static final int KEYUP_HOLD_RESERVE_MS = 500; + + /** + * Latest point in the slot, in ms from the boundary, that key-up may be held to while + * waiting for the fast decode of the slot that just ended. + * + *

The waveform occupies {@code slotMillis - audioSlackMillis}, so any start within + * the slack fits with zero clipping. This spends part of that otherwise-idle headroom — + * but only the part left after reserving what key-up itself costs, so a held + * transmission still starts within the slack and clips nothing. + * + *

Returns 0 (no hold at all) if the reserves already consume the slack, which keeps + * a mode with little or no slack, or an extreme {@code pttDelay}, strictly at today's + * behaviour rather than borrowing margin it does not have. + * + * @param audioSlackMillis {@link ModeProfile#audioSlackMillis} — free slack before clipping + * @param pttDelayMs {@code GeneralVariables.pttDelay}, the configured PTT settle time + */ + static long keyUpHoldLimitMs(int audioSlackMillis, int pttDelayMs) { + long limit = (long) audioSlackMillis - Math.max(0, pttDelayMs) - KEYUP_HOLD_RESERVE_MS; + return Math.max(0, limit); + } + + /** + * Absolute instant to stop waiting for the fast decode, or {@code boundaryMs} (i.e. no + * wait) when there is no headroom to spend. + * + * @param boundaryMs wall-clock instant of the slot boundary this TX belongs to + * @param audioSlackMillis see {@link #keyUpHoldLimitMs} + * @param pttDelayMs see {@link #keyUpHoldLimitMs} + */ + static long keyUpHoldDeadline(long boundaryMs, int audioSlackMillis, int pttDelayMs) { + return boundaryMs + keyUpHoldLimitMs(audioSlackMillis, pttDelayMs); + } + + /** Set by MainViewModel once the listener exists; null until then (and in tests). */ + private volatile FastDecodeGate fastDecodeGate; + + public void setFastDecodeGate(FastDecodeGate gate) { + this.fastDecodeGate = gate; + } + + /** + * Block briefly, if needed, so this transmission carries the result of the fast decode + * of the slot that just ended. No-op when nothing is decoding. + * + *

Runs on the cycle-timer's pooled callback thread. Blocking there is safe — the + * pool creates threads on demand and this timer fires once per slot — and the wait is + * hard-bounded by {@link #keyUpHoldDeadline}, so a decode that never finishes delays + * key-up by at most the spare slack rather than stalling the cycle. + */ + private void holdForFastDecode(long utc) { + FastDecodeGate gate = fastDecodeGate; + if (gate == null || !gate.inFlight()) return; + ModeProfile mode = GeneralVariables.currentMode(); + long nowMs = System.currentTimeMillis(); + // Anchor the deadline to THIS slot's boundary, derived from the same corrected + // clock the cycle timer fires on, so a clock correction cannot make the hold + // measure from the wrong place. + long boundaryMs = nowMs - (UtcTimer.getSystemTime() % mode.slotMillis); + long deadline = keyUpHoldDeadline(boundaryMs, mode.audioSlackMillis, + GeneralVariables.pttDelay); + if (deadline <= nowMs) return; + boolean landed = gate.awaitIdle(deadline, nowMs); + long heldMs = System.currentTimeMillis() - nowMs; + if (heldMs > 0) { + GeneralVariables.fileLog("QSO: held key-up " + heldMs + "ms for fast decode (" + + (landed ? "landed" : "timed out") + ")"); + } + } + /** * Whether the playback path these modes select can actually be interrupted mid-buffer. * diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ft8listener/FastDecodeGateTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ft8listener/FastDecodeGateTest.java new file mode 100644 index 000000000..c8d91937a --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ft8listener/FastDecodeGateTest.java @@ -0,0 +1,98 @@ +package com.k1af.ft8af.ft8listener; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Tests for {@link FastDecodeGate}, the handshake that lets the transmitter wait for the + * fast decode of the slot that just ended. + * + *

Why it exists, measured across one morning's activation: on a busy band (14.2 decodes + * per cycle) 35 fast deliveries landed after key-up and their callers were answered a cycle + * late; once the band quietened (6.3 per cycle) that count was zero. The failure scales + * with band activity, which is backwards — a pileup is when auto-answer matters most. + * + *

The bound that keeps the wait safe lives in {@code FT8TransmitSignalKeyUpHoldTest} + * (package-private to ft8transmit). + */ +public class FastDecodeGateTest { + + @Test + public void idleGateReturnsImmediately() { + // The quiet-band case, and most cycles: key-up timing must be untouched. + FastDecodeGate gate = new FastDecodeGate(); + assertThat(gate.inFlight()).isFalse(); + long before = System.currentTimeMillis(); + assertThat(gate.awaitIdle(before + 5_000, before)).isTrue(); + assertThat(System.currentTimeMillis() - before).isLessThan(500L); + } + + @Test + public void beginMarksInFlight() { + FastDecodeGate gate = new FastDecodeGate(); + gate.begin(); + assertThat(gate.inFlight()).isTrue(); + gate.end(); + assertThat(gate.inFlight()).isFalse(); + } + + @Test + public void awaitReturnsWhenTheDecodeFinishes() throws Exception { + FastDecodeGate gate = new FastDecodeGate(); + gate.begin(); + Thread decode = new Thread(() -> { + try { + Thread.sleep(80); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + gate.end(); + }); + decode.start(); + + long before = System.currentTimeMillis(); + boolean landed = gate.awaitIdle(before + 5_000, before); + long waited = System.currentTimeMillis() - before; + decode.join(); + + assertThat(landed).isTrue(); + assertThat(gate.inFlight()).isFalse(); + // Released by the decode finishing, not by burning the whole deadline. + assertThat(waited).isLessThan(4_000L); + } + + @Test + public void awaitGivesUpAtTheDeadlineWhenTheDecodeNeverFinishes() { + // A hung decode must delay key-up by the bound, never indefinitely. + FastDecodeGate gate = new FastDecodeGate(); + gate.begin(); + long before = System.currentTimeMillis(); + boolean landed = gate.awaitIdle(before + 120, before); + long waited = System.currentTimeMillis() - before; + + assertThat(landed).isFalse(); + assertThat(waited).isAtLeast(100L); + assertThat(waited).isLessThan(3_000L); + } + + @Test + public void aDeadlineAlreadyPastDoesNotWait() { + FastDecodeGate gate = new FastDecodeGate(); + gate.begin(); + long now = System.currentTimeMillis(); + long before = System.currentTimeMillis(); + assertThat(gate.awaitIdle(now - 1_000, now)).isFalse(); + assertThat(System.currentTimeMillis() - before).isLessThan(500L); + } + + @Test + public void endIsIdempotentAndUnblocksEvenIfCalledTwice() { + FastDecodeGate gate = new FastDecodeGate(); + gate.begin(); + gate.end(); + gate.end(); + long now = System.currentTimeMillis(); + assertThat(gate.awaitIdle(now + 5_000, now)).isTrue(); + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignalKeyUpHoldTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignalKeyUpHoldTest.java new file mode 100644 index 000000000..0d24a0776 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignalKeyUpHoldTest.java @@ -0,0 +1,89 @@ +package com.k1af.ft8af.ft8transmit; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Tests for the bound on the adaptive key-up hold — how long the transmitter may wait for + * the fast decode of the slot that just ended before it must key up regardless. + * + *

The hold spends otherwise-idle audio slack, so the load-bearing property is that it + * can never spend so much that the transmission starts past the slack and has its leading + * Costas sync array clipped. That defect makes a signal loud on the air and undecodable, + * and this codebase has already shipped it twice; the reserves below exist to keep a third + * time from being possible. + * + *

The gate itself is covered by {@code FastDecodeGateTest}. + */ +public class FT8TransmitSignalKeyUpHoldTest { + + /** FT8: 15000 ms slot, 12640 ms of audio. */ + private static final int FT8_SLACK_MS = 2360; + /** FT4: 7500 ms slot, 5040 ms of audio. */ + private static final int FT4_SLACK_MS = 2460; + private static final long T0 = 1_700_000_000_000L; + + @Test + public void holdLimitLeavesRoomForKeyUpCosts() { + long limit = FT8TransmitSignal.keyUpHoldLimitMs(FT8_SLACK_MS, 100); + assertThat(limit) + .isEqualTo(FT8_SLACK_MS - 100 - FT8TransmitSignal.KEYUP_HOLD_RESERVE_MS); + } + + @Test + public void aHeldStartStillFitsInsideTheSlack() { + // The property that matters, stated directly: hold + PTT + generation must not + // exceed the slack, or the waveform gets its leading sync array clipped. + for (int ptt : new int[] {0, 100, 400, 1_000}) { + long limit = FT8TransmitSignal.keyUpHoldLimitMs(FT8_SLACK_MS, ptt); + assertThat(limit + ptt + FT8TransmitSignal.KEYUP_HOLD_RESERVE_MS) + .isAtMost((long) FT8_SLACK_MS); + } + } + + @Test + public void holdLimitIsWorthHaving() { + // Key-up currently lands ~450 ms into the slot, so the limit must be meaningfully + // beyond that or the hold buys no decode time at all. + assertThat(FT8TransmitSignal.keyUpHoldLimitMs(FT8_SLACK_MS, 100)).isGreaterThan(450L); + } + + @Test + public void aLargePttDelayShrinksTheHoldRatherThanBorrowingMargin() { + assertThat(FT8TransmitSignal.keyUpHoldLimitMs(FT8_SLACK_MS, 1_500)) + .isEqualTo(FT8_SLACK_MS - 1_500 - FT8TransmitSignal.KEYUP_HOLD_RESERVE_MS); + } + + @Test + public void noSlackToSpendMeansNoHold() { + // Reserves exceed the slack: fall back to today's behaviour rather than clipping. + assertThat(FT8TransmitSignal.keyUpHoldLimitMs(400, 100)).isEqualTo(0L); + assertThat(FT8TransmitSignal.keyUpHoldLimitMs(0, 0)).isEqualTo(0L); + assertThat(FT8TransmitSignal.keyUpHoldLimitMs(FT8_SLACK_MS, 99_999)).isEqualTo(0L); + } + + @Test + public void negativePttDelayIsTreatedAsZero() { + assertThat(FT8TransmitSignal.keyUpHoldLimitMs(FT8_SLACK_MS, -50)) + .isEqualTo(FT8TransmitSignal.keyUpHoldLimitMs(FT8_SLACK_MS, 0)); + } + + @Test + public void ft4GetsItsOwnSlack() { + assertThat(FT8TransmitSignal.keyUpHoldLimitMs(FT4_SLACK_MS, 100)) + .isGreaterThan(FT8TransmitSignal.keyUpHoldLimitMs(FT8_SLACK_MS, 100)); + } + + @Test + public void deadlineIsAnchoredToTheBoundary() { + assertThat(FT8TransmitSignal.keyUpHoldDeadline(T0, FT8_SLACK_MS, 100)) + .isEqualTo(T0 + FT8TransmitSignal.keyUpHoldLimitMs(FT8_SLACK_MS, 100)); + } + + @Test + public void deadlineWithNoHeadroomEqualsTheBoundary() { + // Equal to "now at the boundary", which the caller treats as "do not wait". + assertThat(FT8TransmitSignal.keyUpHoldDeadline(T0, 400, 100)).isEqualTo(T0); + } +} From a77776ea0c38f73d2ff39579b603bd33aa49743c Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sat, 1 Aug 2026 17:26:32 -0500 Subject: [PATCH 102/113] RTOTA trip mode: live GPS route + QSOs, sampled with SmartBeaconing (#710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add RTOTA trip mode: live GPS route + QSOs to rtota.app Roving operators can now record a road trip from the app and have it appear live on rtota.app instead of waiting for an end-of-trip ADIF upload. Settings -> Road Trips (RTOTA) registers the callsign (or takes a pasted API key), starts and ends a trip, shows what has reached the server, and announces a planned trip to followers. While a trip runs, a location-typed foreground service owns the GPS subscription so breadcrumbs keep coming with the screen off, and every QSO written to the log is queued for the same trip (bulk ADIF imports excluded -- the hook rides the existing appendToAdifFile gate). Built for the failure mode roving actually has: no coverage. * Everything recorded lands in an on-disk queue before any network attempt, so a canyon, a reboot, or an OS kill costs time and nothing else. * A trip can be started AND ended with no signal at all; creation and completion are deferred flags the flush loop resolves later. * Flushes are single-flight, batched, backed off to 15 minutes, and triggered immediately when a validated network appears. * TripPointSampler cuts a 1 Hz fix stream down to the shape of the route (time floor, distance floor, plus a point through a turn) and stays silent while parked, which is what lets the server derive overnight stops. The API key lives in Keystore-backed EncryptedSharedPreferences rather than the config table, which the settings-backup export copies verbatim. 58 unit tests cover the sampler, the queue (including a killed-mid-write file), the wire payloads against the server's zod schemas, QSO mapping, and the client's request shape + retry classification via MockWebServer. * Sample the route with SmartBeaconing and pin QSOs to the path Replaces the fixed interval/distance sampler with SmartBeaconing (TM) - the HamHUD scheme APRSdroid, the Kenwood D710 and most APRS trackers use - so the recorded breadcrumbs actually draw the road. Rate follows speed: below the slow threshold beacon rarely, above the fast one beacon at the fast rate, and in between fastRate x fastSpeed / speed, which holds the spacing of points roughly constant instead of the time. Corner pegging sends a point as soon as the course changes by more than minTurnAngle + turnSlope/speed. That division is the whole trick: at 65 mph the threshold is ~19 degrees (an interstate curve), at 25 mph it is ~25, and at walking pace it is effectively unreachable. Measured on a replayed 8.5-mile drive with a 30 s sweeping curve (SmartBeaconRouteFidelityTest): worst deviation of the real path from the drawn polyline is 29 m with corner pegging and 120 m with interval-only sampling, a curve cut across four times wider than the highway itself. Two departures from stock SmartBeaconing, both documented in the profile: * A corner also requires real movement (turnMinDistanceM). APRS trackers read course from GPS velocity, which is undefined when stopped; Android keeps reporting a bearing, so a phone idling at a light would otherwise grow a scribble of points where the truck never moved. A test drives exactly that. * A true standstill emits nothing at all. APRS keeps beaconing to stay visible; RTOTA derives overnight stops from 4h+ gaps, so silence while parked is the signal. Departure is beaconed immediately (RESUME) so the stop is bounded. QSOs now plot on the line rather than beside it. Contacts are stamped from the freshest fix instead of the last beacon (which on an interstate can be half a mile back), and that position is forced into the route as a QSO-anchored vertex, deduped when a point is already within 20 s and 25 m. Ending a trip anchors the final position the same way, so the line stops where the rover did. Profiles (Car / Bicycle / Walking) replace the raw interval and distance rows; the screen prints what the chosen profile will actually do, and the trip card and notification now show which rule kept the last point (corner, contact, parked) so the behaviour is legible from the passenger seat. 75 RTOTA tests: rate curve, turn threshold, speed fallback, corner pegging and its guards, parked silence over an 8 h stop, resume, QSO anchoring, plus the route-fidelity replay above. * Drop the bicycle and walking beacon profiles RTOTA is a road-trip service: the rover is in a vehicle. The other two profiles were speculative, and a picker with one sensible answer is a setting the user has to think about for nothing. SmartBeaconProfile keeps its parameters (they are still worth naming and documenting in one place, and the fidelity test builds variants with copy() to isolate a single rule) but loses the key/ALL/byKey machinery, the stored preference, the mid-trip setter, and the tap-to-cycle row. The tracking section is now a read-only line stating what the sampler does -- still worth saying, since a trail that goes quiet at a fuel stop otherwise reads as broken. 72 RTOTA tests still pass; the route-fidelity numbers are unchanged. * Make trip mode transmittable, deliverable, and locatable Five things stood between RTOTA trip mode and a real drive. **The base URL could never have worked.** DEFAULT_BASE_URL was the apex https://rtota.app, which 308-redirects to www. No HTTP client may follow a 308 for a POST (RFC 9110: the method and body have to survive, so clients decline rather than guess), and every write here is a POST — so trip creation failed permanently, non-retryably, with the whole queue stranded behind it. normalizeRtotaBaseUrl repairs the host on read as well as write, so an install that already persisted the apex heals on upgrade instead of 308-ing forever. **"CQ RTOTA" is not encodable, and was not wired at all.** Nothing in the package ever touched GeneralVariables.toModifier. And the token itself has no encoding: an FT8 standard message packs the CQ into the 28-bit c28 field, whose vocabulary is CQ plus one to four letters. POTA fits at exactly four; RTOTA is five, and Ft8Message drops an over-long modifier silently — you would transmit a bare CQ all day and only find out afterwards. RtotaCqSession imposes RTOA for the duration of a trip and hands the modifier back on the way out. It composes with POTA's own save/restore: a park activation started mid-trip banks RTOA and returns it when it ends, and ending the trip while POTA holds the modifier leaves it alone rather than clobbering an active activation. **Resumed trips re-sent everything.** The service grew a sync-state handshake; a trip resumed from disk now asks what the server already holds and prunes acknowledged contacts by exact dedupe key, turning "re-send the whole day" into "send the last few minutes". Matched on the service's exact key format, because a near-miss merely re-sends a QSO that dedupes anyway while a false match would discard one that never arrived. **Breadcrumbs never carried a highway.** onLocationFix set state but never highway, so the service's highwaysTraveled roll-up was always empty. HighwayResolver names the road from a cache with a refresh policy — it never blocks the location callback, throttles on time *and* distance, and expires a label so it can't be carried across a dead zone. An unresolved fix stays null: "not a highway" is a fact, "we don't know" is not, and conflating them would report local roads across a canyon. **QSOs had no position outside trip mode.** Every real-time contact is now stamped with the operator's coordinates, trip or no trip, and the ADIF carries them as standard MY_LAT/MY_LON plus exact decimal APP_RTOTA_ twins. Only real observations qualify — there is deliberately no tier derived from the configured grid, whose centre would dress a ~55 km square up as a measurement. No fix means no coordinates, and rtota.app places the contact from the breadcrumb trail instead. Verified against the live service: register, create, live POST, an identical re-send that deduped to zero, sync-state, complete. The service's own zod schemas and dedupeKey() accept the app's payloads, and its ADIF parser puts the rover back at the exact coordinate written. Co-Authored-By: Claude Opus 5 (1M context) * Address Copilot review on trip-mode permissions and locale (PR #710) **Approximate location no longer blocks Start trip.** The screen gated on ACCESS_FINE_LOCATION while RtotaLocationTracker runs on fine *or* coarse, so answering "Approximate" to the Android 12+ dialog — an ordinary choice — read as a refusal and re-prompted for a permission already granted. There were three copies of this check drifting apart (screen, tracker, RoverPosition); they now share one definition, so the class of bug is gone rather than the instance. **The notification can now say "parked".** The parked transition happens on the not-a-beacon path, which updated state without publishing — and once parked no fix is kept, so recordPoint's publish() never ran either. The notification claimed the rover was still rolling for as long as it sat there. Republished on the transition only: updateNotification does no throttling of its own, and non-beacon fixes arrive about once a second, so publishing every one would rebuild the notification all trip. **Callsigns upper-case in Locale.US.** The bare uppercase() is locale-sensitive for ASCII: under Turkish or Azeri, "i" becomes "İ" (U+0130), so a phone in that locale would store and register a callsign the server can never match — and disagree with RtotaClient, which already normalized with Locale.US. Swept the rest of the package; this was the only bare conversion left. **Fixed a misleading test doc.** syntheticDrive's KDoc called the middle segment a right-hand curve; the heading runs 90° to 0°, which is a left turn, as its own inline comment said. Regression tests for the two testable fixes: the permission predicate under each grant combination (Robolectric), and callsign normalization under Turkish, Azeri, German and US locales. The notification fix is a side effect on a foreground service and isn't reachable without a service harness. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- ft8af/app/src/main/AndroidManifest.xml | 9 + .../com/k1af/ft8af/database/DatabaseOpr.java | 58 +- .../java/com/k1af/ft8af/log/AdifFormat.java | 88 +++ .../java/com/k1af/ft8af/log/AdifLogFile.java | 2 + .../java/com/k1af/ft8af/log/AdifRecord.java | 26 + .../java/com/k1af/ft8af/log/QSLRecord.java | 52 ++ .../java/com/k1af/ft8af/log/ShareLogs.java | 10 + .../radio/ks3ckc/ft8af/ComposeMainActivity.kt | 13 + .../ft8af/location/LocationPermissions.kt | 27 + .../ks3ckc/ft8af/location/RoverPosition.kt | 125 ++++ .../ks3ckc/ft8af/rtota/HighwayClassifier.kt | 158 +++++ .../ks3ckc/ft8af/rtota/HighwayResolver.kt | 201 ++++++ .../radio/ks3ckc/ft8af/rtota/RtotaClient.kt | 277 ++++++++ .../ks3ckc/ft8af/rtota/RtotaCqSession.kt | 129 ++++ .../ft8af/rtota/RtotaLocationTracker.kt | 120 ++++ .../radio/ks3ckc/ft8af/rtota/RtotaModels.kt | 394 +++++++++++ .../ks3ckc/ft8af/rtota/RtotaQsoMapper.kt | 88 +++ .../radio/ks3ckc/ft8af/rtota/RtotaQueue.kt | 180 +++++ .../radio/ks3ckc/ft8af/rtota/RtotaSettings.kt | 211 ++++++ .../ks3ckc/ft8af/rtota/RtotaTripManager.kt | 660 ++++++++++++++++++ .../ks3ckc/ft8af/rtota/RtotaTripService.kt | 213 ++++++ .../ks3ckc/ft8af/rtota/SmartBeaconSampler.kt | 341 +++++++++ .../ks3ckc/ft8af/ui/rtota/RoadTripScreen.kt | 657 +++++++++++++++++ .../ft8af/ui/settings/SettingsScreen.kt | 11 + .../src/main/res/values/strings_compose.xml | 57 ++ .../com/k1af/ft8af/log/AdifLocationTest.java | 134 ++++ .../ft8af/location/LocationPermissionsTest.kt | 68 ++ .../ft8af/location/RoverPositionTest.kt | 85 +++ .../ft8af/rtota/HighwayCachePolicyTest.kt | 101 +++ .../ft8af/rtota/HighwayClassifierTest.kt | 98 +++ .../ft8af/rtota/RoadTripScreenLogicTest.kt | 84 +++ .../ks3ckc/ft8af/rtota/RtotaBaseUrlTest.kt | 66 ++ .../ks3ckc/ft8af/rtota/RtotaCallsignTest.kt | 56 ++ .../ks3ckc/ft8af/rtota/RtotaClientTest.kt | 175 +++++ .../ks3ckc/ft8af/rtota/RtotaCqModifierTest.kt | 73 ++ .../ks3ckc/ft8af/rtota/RtotaPayloadTest.kt | 193 +++++ .../ks3ckc/ft8af/rtota/RtotaQsoMapperTest.kt | 162 +++++ .../ks3ckc/ft8af/rtota/RtotaQueueTest.kt | 146 ++++ .../ks3ckc/ft8af/rtota/RtotaSyncStateTest.kt | 109 +++ .../rtota/SmartBeaconRouteFidelityTest.kt | 163 +++++ .../ft8af/rtota/SmartBeaconSamplerTest.kt | 259 +++++++ 41 files changed, 6076 insertions(+), 3 deletions(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/location/LocationPermissions.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/location/RoverPosition.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/HighwayClassifier.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/HighwayResolver.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClient.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCqSession.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaLocationTracker.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaModels.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQsoMapper.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQueue.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaSettings.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaTripManager.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaTripService.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconSampler.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rtota/RoadTripScreen.kt create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/log/AdifLocationTest.java create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/location/LocationPermissionsTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/location/RoverPositionTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/HighwayCachePolicyTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/HighwayClassifierTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RoadTripScreenLogicTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaBaseUrlTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCallsignTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClientTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCqModifierTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaPayloadTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQsoMapperTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQueueTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaSyncStateTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconRouteFidelityTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconSamplerTest.kt diff --git a/ft8af/app/src/main/AndroidManifest.xml b/ft8af/app/src/main/AndroidManifest.xml index f03c01e30..f4babb197 100644 --- a/ft8af/app/src/main/AndroidManifest.xml +++ b/ft8af/app/src/main/AndroidManifest.xml @@ -29,6 +29,9 @@ (Android 14 mutes background mic without a microphone-typed foreground service). --> + + + + + diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index e449d1122..c8c2d4312 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -273,6 +273,13 @@ private void createQSLTable(SQLiteDatabase sqLiteDatabase) { , "sig TEXT"); alterTable(sqLiteDatabase, "QSLTable", "sig_info" , "sig_info TEXT"); + // Where the operator was when the contact was made. REAL and nullable: + // rows logged before this column existed genuinely have no position, and + // 0/0 is a real place in the Gulf of Guinea, not a "missing" marker. + alterTable(sqLiteDatabase, "QSLTable", "my_lat" + , "my_lat REAL"); + alterTable(sqLiteDatabase, "QSLTable", "my_lon" + , "my_lon REAL"); } else { sqLiteDatabase.execSQL("CREATE TABLE QSLTable (\n" + @@ -301,7 +308,9 @@ private void createQSLTable(SQLiteDatabase sqLiteDatabase) { "my_sig TEXT,\n" +//POTA: activator's program ("POTA") "my_sig_info TEXT,\n" +//POTA: activator's park ref "sig TEXT,\n" +//POTA: worked station's program - "sig_info TEXT)");//POTA: worked station's park ref + "sig_info TEXT,\n" +//POTA: worked station's park ref + "my_lat REAL,\n" +//Operator's latitude at QSO time (null = unknown) + "my_lon REAL)");//Operator's longitude at QSO time } @@ -1285,6 +1294,8 @@ static AdifRecord adifRecordFromCursor(Cursor cursor, boolean isSWL) { .mySigInfo(colStr(cursor, "my_sig_info")) .sig(colStr(cursor, "sig")) .sigInfo(colStr(cursor, "sig_info")) + .myLat(colDouble(cursor, "my_lat")) + .myLon(colDouble(cursor, "my_lon")) .comment(colStr(cursor, "comment")); } @@ -1300,6 +1311,20 @@ private static int colInt(Cursor cursor, String column) { return idx < 0 ? 0 : cursor.getInt(idx); } + /** + * Cursor double read that tolerates the column being absent or null. + * + *

Returns a boxed {@code Double} rather than a primitive because null is a real + * answer here: a QSO logged before the position columns existed, or with location + * permission denied, has no coordinates — and 0.0 is a valid position in the Gulf of + * Guinea, so it cannot double as "unknown". + */ + private static Double colDouble(Cursor cursor, String column) { + int idx = cursor.getColumnIndex(column); + if (idx < 0 || cursor.isNull(idx)) return null; + return cursor.getDouble(idx); + } + /** * Populate the "already worked" DXCC / CQ-zone / ITU-zone sets from the logbook. * @@ -1468,6 +1493,23 @@ public boolean doInsertQSLData(QSLRecord record, AfterInsertQSLData afterInsertQ record, radio.ks3ckc.ft8af.pota.PotaSpotsRepository.parkRefFor(record.getToCallsign())); + // Stamp where the operator was, for every real-time contact — not just during an + // RTOTA trip. The ADIF this ends up in is the copy that reaches rtota.app, QRZ, + // Cloudlog and LoTW, and a QSO without a position can only ever be placed at the + // grid's centre by whoever reads it. + // + // Two guards, both load-bearing. `appendToAdifFile` is false for bulk ADIF + // imports, which must not be branded with today's coordinates; and a record that + // already carries a position (imported, or replayed) keeps the one it came with. + if (appendToAdifFile && !record.hasMyPosition()) { + radio.ks3ckc.ft8af.location.RoverFix fix = + radio.ks3ckc.ft8af.location.RoverPosition.INSTANCE.current(context); + if (fix != null) { + record.setMyLat(fix.getLatitude()); + record.setMyLon(fix.getLongitude()); + } + } + String querySQL; if (!checkQSLCallsign(record)) {//If record doesn't exist, add it querySQL = "INSERT INTO QslCallsigns (callsign" + @@ -1517,7 +1559,8 @@ public boolean doInsertQSLData(QSLRecord record, AfterInsertQSLData afterInsertQ if (!checkIsQSL(record)) {//If log data doesn't exist, add it querySQL = "INSERT INTO QSLTable(call, isQSL,isLotW_import,isLotW_QSL,gridsquare, mode, rst_sent, rst_rcvd, qso_date, " + "time_on, qso_date_off, time_off, band, freq, station_callsign, my_gridsquare," + - "comment,my_sig,my_sig_info,sig,sig_info)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"; + "comment,my_sig,my_sig_info,sig,sig_info,my_lat,my_lon)" + + "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"; db.execSQL(querySQL, new String[]{record.getToCallsign() , String.valueOf(record.isQSL ? 1 : 0) @@ -1540,7 +1583,11 @@ public boolean doInsertQSLData(QSLRecord record, AfterInsertQSLData afterInsertQ , record.getMySig() , record.getMySigInfo() , record.getSig() - , record.getSigInfo()}); + , record.getSigInfo() + // Bound as strings like every other value here; SQLite's REAL column + // affinity converts them on the way in, and a null binds as NULL. + , record.getMyLat() == null ? null : String.valueOf(record.getMyLat()) + , record.getMyLon() == null ? null : String.valueOf(record.getMyLon())}); // If this QSO was logged during an active POTA activation, bump its qso_count. if (record.getMySigInfo() != null && !record.getMySigInfo().isEmpty()) { db.execSQL("UPDATE pota_activation SET qso_count = qso_count + 1 " @@ -1551,6 +1598,11 @@ public boolean doInsertQSLData(QSLRecord record, AfterInsertQSLData afterInsertQ // or missing SD can never break QSO logging (AdifLogFile.logQso itself never throws). if (appendToAdifFile) { com.k1af.ft8af.log.AdifLogFile.logQso(context, record); + // RTOTA trip mode: queue the contact for the live road-trip feed. Gated on + // the same appendToAdifFile flag as the ADIF mirror, so a bulk log import + // can't inject a thousand historic QSOs into today's drive. No-op unless a + // trip is running; never throws (see RtotaTripManager.onQsoLogged). + radio.ks3ckc.ft8af.rtota.RtotaTripManager.onQsoLogged(record); } if (afterInsertQSLData!=null){ afterInsertQSLData.doAfterInsert(false,true);//New QSL diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifFormat.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifFormat.java index b7bdf5ada..7433eb808 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifFormat.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifFormat.java @@ -160,4 +160,92 @@ public static String sliceByUtf8Length(String raw, int byteLen) { } return raw.substring(0, i); } + + /** + * Format a coordinate in ADIF's Location datatype: {@code XDDD MM.MMM}, i.e. a + * hemisphere letter, three zero-padded degrees, a space, then decimal minutes — + * {@code N039 44.352}, {@code W104 59.418}. + * + *

Degrees are always three digits even for a latitude that can never exceed 90, + * because the spec fixes the width and importers parse by position. The awkward + * carry case is real and handled below: 59.9996 minutes rounds to 60.000, which is + * not a valid minute value, so the degree is incremented and the minutes wrap to + * zero rather than emitting {@code N039 60.000}. + * + * @param value degrees, positive north/east + * @param latitude true for a latitude (N/S), false for a longitude (E/W) + * @return the formatted value, or null when {@code value} is absent or not finite + */ + public static String location(Double value, boolean latitude) { + if (value == null || Double.isNaN(value) || Double.isInfinite(value)) { + return null; + } + double limit = latitude ? 90.0 : 180.0; + if (Math.abs(value) > limit) { + return null; + } + char hemisphere = value < 0 ? (latitude ? 'S' : 'W') : (latitude ? 'N' : 'E'); + double magnitude = Math.abs(value); + int degrees = (int) magnitude; + double minutes = (magnitude - degrees) * 60.0; + // Round to the emitted precision *before* formatting so the carry is visible. + double rounded = Math.round(minutes * 1000.0) / 1000.0; + if (rounded >= 60.0) { + rounded -= 60.0; + degrees += 1; + } + return String.format(Locale.US, "%c%03d %06.3f", hemisphere, degrees, rounded); + } + + /** + * Parse ADIF's Location datatype ({@code N039 44.352}) back to decimal degrees. + * + *

Lenient about the separator and about a missing leading zero, because exporters + * vary; strict about the hemisphere letter, which is the only thing carrying the + * sign. Returns null rather than guessing when the shape doesn't match — a + * mis-parsed coordinate puts a QSO in the wrong hemisphere, which is worse than + * having none. + */ + public static Double parseLocation(String raw) { + if (raw == null) { + return null; + } + String s = raw.trim().toUpperCase(Locale.US); + if (s.length() < 2) { + return null; + } + char hemisphere = s.charAt(0); + if (hemisphere != 'N' && hemisphere != 'S' && hemisphere != 'E' && hemisphere != 'W') { + return null; + } + String[] parts = s.substring(1).trim().split("\\s+"); + if (parts.length != 2) { + return null; + } + try { + int degrees = Integer.parseInt(parts[0]); + double minutes = Double.parseDouble(parts[1]); + if (degrees < 0 || minutes < 0 || minutes >= 60.0) { + return null; + } + double value = degrees + minutes / 60.0; + return (hemisphere == 'S' || hemisphere == 'W') ? -value : value; + } catch (NumberFormatException e) { + return null; + } + } + + /** + * Format a coordinate as plain decimal degrees for an {@code APP_}-prefixed field. + * + *

Six decimal places is about 11 cm — far beyond any consumer GPS, and enough + * that the value round-trips without the loss the degrees-and-minutes form imposes. + * Trailing zeros are kept so the field width is stable across records. + */ + public static String decimalDegrees(Double value) { + if (value == null || Double.isNaN(value) || Double.isInfinite(value)) { + return null; + } + return String.format(Locale.US, "%.6f", value); + } } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifLogFile.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifLogFile.java index 96bf3a868..7c5a2b3a4 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifLogFile.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifLogFile.java @@ -93,6 +93,8 @@ public static AdifRecord fromQslRecord(QSLRecord record) { .mySigInfo(record.getMySigInfo()) .sig(record.getSig()) .sigInfo(record.getSigInfo()) + .myLat(record.getMyLat()) + .myLon(record.getMyLon()) .comment(record.getComment()); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifRecord.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifRecord.java index 2361d016c..e9b27fa2e 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifRecord.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifRecord.java @@ -42,6 +42,19 @@ public final class AdifRecord { */ public static final String APP_QSL_MANUAL = "APP_FT8AF_QSL_MANUAL"; + /** + * Application-defined fields carrying the operator's position as a plain decimal + * degree, alongside the standard {@code MY_LAT}/{@code MY_LON}. + * + *

Both are written because they serve different readers. ADIF's own location + * datatype is {@code XDDD MM.MMM} — degrees and decimal minutes — which every + * conventional logbook understands but which rounds to about 1.8 m and has to be + * re-parsed. rtota.app prefers these decimal twins, so a route replay gets back + * exactly the coordinate the phone recorded rather than a rounded reconstruction. + */ + public static final String APP_RTOTA_LAT = "APP_RTOTA_LAT"; + public static final String APP_RTOTA_LON = "APP_RTOTA_LON"; + /** Legacy, non-conformant name for {@link #APP_QSL_MANUAL}. Read-only: never emitted. */ public static final String LEGACY_QSL_MANUAL = "QSL_MANUAL"; @@ -67,6 +80,9 @@ public final class AdifRecord { private String sig; private String sigInfo; private String comment; + /** Operator's position at QSO time; null when the app had no fix to record. */ + private Double myLat; + private Double myLon; public AdifRecord call(String v) { this.call = v; return this; } @@ -106,6 +122,8 @@ public final class AdifRecord { public AdifRecord mySig(String v) { this.mySig = v; return this; } public AdifRecord mySigInfo(String v) { this.mySigInfo = v; return this; } + public AdifRecord myLat(Double v) { this.myLat = v; return this; } + public AdifRecord myLon(Double v) { this.myLon = v; return this; } public AdifRecord sig(String v) { this.sig = v; return this; } @@ -162,6 +180,14 @@ public String build() { appendIfNotEmpty(sb, "MY_SIG_INFO", mySigInfo); appendIfNotEmpty(sb, "SIG", sig); appendIfNotEmpty(sb, "SIG_INFO", sigInfo); + // Operator position, standard field first then the exact decimal twin. Both + // are emitted or neither: a lone longitude is worse than no position at all. + if (myLat != null && myLon != null) { + appendIfNotEmpty(sb, "MY_LAT", AdifFormat.location(myLat, true)); + appendIfNotEmpty(sb, "MY_LON", AdifFormat.location(myLon, false)); + appendIfNotEmpty(sb, APP_RTOTA_LAT, AdifFormat.decimalDegrees(myLat)); + appendIfNotEmpty(sb, APP_RTOTA_LON, AdifFormat.decimalDegrees(myLon)); + } appendIfNotEmpty(sb, "comment", comment); sb.append("\n"); return sb.toString(); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/QSLRecord.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/QSLRecord.java index 2dbad4992..ca79406ae 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/log/QSLRecord.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/QSLRecord.java @@ -48,6 +48,11 @@ public class QSLRecord { private String mySigInfo; private String sig; private String sigInfo; + // Where the operator was when this contact was made. Boxed because null is a real + // answer — no fix, permission denied, or a row logged before the columns existed — + // and 0.0 is a genuine position, so it cannot stand in for "unknown". + private Double myLat; + private Double myLon; public boolean isQSL = false;//Manual confirmation public boolean isLotW_import = false;//Whether imported from external data; requires database comparison to determine public boolean isLotW_QSL = false;//Whether confirmed via LoTW @@ -252,6 +257,31 @@ public QSLRecord(HashMap map) { if (map.containsKey("SIG")) sig = map.get("SIG"); if (map.containsKey("SIG_INFO")) sigInfo = map.get("SIG_INFO"); + // Operator position on import. The APP_RTOTA_ decimal pair is preferred over the + // standard MY_LAT/MY_LON because it is what this app wrote and round-trips + // exactly; the standard fields are the fallback for logs from anywhere else. + Double lat = parseAdifDecimal(map.get("APP_RTOTA_LAT")); + Double lon = parseAdifDecimal(map.get("APP_RTOTA_LON")); + if (lat == null || lon == null) { + lat = AdifFormat.parseLocation(map.get("MY_LAT")); + lon = AdifFormat.parseLocation(map.get("MY_LON")); + } + // Both or neither: half a coordinate places a QSO on the equator or the meridian. + if (lat != null && lon != null) { + myLat = lat; + myLon = lon; + } + } + + /** Plain decimal-degree parse for the APP_RTOTA_ fields; null when unusable. */ + private static Double parseAdifDecimal(String raw) { + if (raw == null || raw.trim().isEmpty()) return null; + try { + double v = Double.parseDouble(raw.trim()); + return Double.isFinite(v) ? v : null; + } catch (NumberFormatException e) { + return null; + } } /** @@ -409,6 +439,28 @@ public void setTime_on(String time_on) { this.time_on = time_on; } + /** Operator latitude at QSO time, or null when the app had no position to record. */ + public Double getMyLat() { + return myLat; + } + + public void setMyLat(Double myLat) { + this.myLat = myLat; + } + + public Double getMyLon() { + return myLon; + } + + public void setMyLon(Double myLon) { + this.myLon = myLon; + } + + /** True once both halves of a position are present — the only usable state. */ + public boolean hasMyPosition() { + return myLat != null && myLon != null; + } + public String getMySig() { return mySig; } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/ShareLogs.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/ShareLogs.java index 65ece5d2b..c693fa65c 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/log/ShareLogs.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/ShareLogs.java @@ -320,6 +320,9 @@ static AdifRecord recordFromCursor(android.database.Cursor cursor, boolean isSWL .mySigInfo(col(cursor, "my_sig_info")) .sig(col(cursor, "sig")) .sigInfo(col(cursor, "sig_info")) + // Operator position, when the QSO was logged with one. + .myLat(colDouble(cursor, "my_lat")) + .myLon(colDouble(cursor, "my_lon")) // comment is always emitted (null renders as an empty field), as before. .comment(col(cursor, "comment")); if (!isSWL) { @@ -336,6 +339,13 @@ private static String col(android.database.Cursor cursor, String column) { return cursor.getString(idx); } + /** Nullable double read — absent column and SQL NULL both mean "no position". */ + private static Double colDouble(android.database.Cursor cursor, String column) { + int idx = cursor.getColumnIndex(column); + if (idx < 0 || cursor.isNull(idx)) return null; + return cursor.getDouble(idx); + } + /** Cursor int value, or 0 when the column is absent. */ private static int colInt(android.database.Cursor cursor, String column) { int idx = cursor.getColumnIndex(column); diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt index 4b57c99af..c4b192012 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt @@ -37,6 +37,7 @@ import com.k1af.ft8af.GeneralVariables import com.k1af.ft8af.MainViewModel import com.k1af.ft8af.R import radio.ks3ckc.ft8af.crash.CrashReporting +import radio.ks3ckc.ft8af.rtota.RtotaTripManager import radio.ks3ckc.ft8af.sync.QsoAutoSync import radio.ks3ckc.ft8af.util.bluetoothAdapter import com.k1af.ft8af.service.RxForegroundService @@ -194,6 +195,11 @@ class ComposeMainActivity : AppCompatActivity() { syncNow("app-start") } + // RTOTA trip mode: restore an in-flight road trip (one the phone was killed or + // rebooted in the middle of), resume GPS tracking, and push whatever queued up + // offline. No-op unless trip mode is enabled and a trip is actually running. + RtotaTripManager.init(applicationContext) + // Set Compose UI — splash plays once per cold start, then crossfades into the app. setContent { FT8AFTheme { @@ -421,6 +427,13 @@ class ComposeMainActivity : AppCompatActivity() { // persisted operating mode is known, rebuild them for it and sync the UI. mainViewModel.applyLoadedOperatingMode() + // Re-impose "CQ RTOA" if a road trip was running when the app closed. + // Must come after the config load above (which assigns toModifier from + // the stored row) and before the POTA resume below: a park activation + // started mid-trip outranks the trip, and resuming in this order lets + // POTA save RTOA as its own predecessor and hand it back when it ends. + RtotaTripManager.onConfigLoaded() + // Resume any POTA activation that was interrupted by app close PotaSessionManager.resume() diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/location/LocationPermissions.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/location/LocationPermissions.kt new file mode 100644 index 000000000..b64f76374 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/location/LocationPermissions.kt @@ -0,0 +1,27 @@ +package radio.ks3ckc.ft8af.location + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat + +/** + * Whether the app may read the device's location at all. + * + * One definition, deliberately, because the interesting case is the one two + * copies get to disagree about. Since Android 12 the permission dialog offers + * "Precise" and "Approximate" as a user choice, so `ACCESS_COARSE_LOCATION` + * alone is a perfectly ordinary outcome of asking for both. Code that tests only + * for `ACCESS_FINE_LOCATION` reads that grant as a denial — and a screen that + * gates on the strict check while the tracker underneath it accepts the loose one + * refuses to start a trip it could have recorded fine, re-prompting for a + * permission the user already granted. + * + * Trip mode is happy with approximate: SmartBeaconing samples a route measured in + * miles, and a QSO is pinned to the road rather than to a lane. + */ +fun hasLocationPermission(context: Context): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/location/RoverPosition.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/location/RoverPosition.kt new file mode 100644 index 000000000..b0835afae --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/location/RoverPosition.kt @@ -0,0 +1,125 @@ +package radio.ks3ckc.ft8af.location + +import android.content.Context +import android.location.LocationManager +import android.util.Log +import radio.ks3ckc.ft8af.rtota.RtotaTripManager + +/** + * Where a position came from, in descending order of trustworthiness. + * + * Both entries are *observations* — an actual fix from an actual receiver. There + * is deliberately no tier derived from the operator's configured grid: a + * four-character grid is roughly 55 km across, and writing its centre into + * MY_LAT/MY_LON would dress that up as a measurement. The ADIF already carries + * MY_GRIDSQUARE, so anyone reading the log can make that approximation + * themselves, knowing exactly what it is. When there is no fix, the QSO is + * logged without coordinates and rtota.app infers a position from the breadcrumb + * trail instead. + */ +enum class RoverPositionSource { + /** A live GPS fix from an active road trip — the rover's actual position. */ + LIVE_FIX, + + /** The platform's last known location, from whatever asked for one last. */ + LAST_KNOWN, +} + +/** A candidate position with enough context to judge whether it is worth using. */ +data class RoverFix( + val latitude: Double, + val longitude: Double, + /** How old the underlying observation is, in ms. A grid centre is never "old". */ + val ageMs: Long, + val source: RoverPositionSource, +) + +/** + * A real fix older than this is not where the operator is now. + * + * Fifteen minutes is a compromise: long enough that a phone that has been sitting + * in a parking lot still has a usable last-known location, short enough that at + * highway speed the error stays in the tens of miles rather than the hundreds — + * and below that threshold a stale fix is still far better than the ~55 km + * ambiguity of a four-character grid. + */ +const val MAX_ROVER_FIX_AGE_MS = 15 * 60_000L + +/** + * Pick the position to stamp on a QSO from the candidates available. + * + * Pure, and the only decision worth testing here: take the highest-priority + * candidate that isn't stale. Priority beats freshness on purpose — a live trip + * fix from ten minutes ago is a better account of where the operator was than a + * last-known location of unknown provenance. + * + * Returns null when nothing qualifies, and that is a real answer: the QSO is + * logged with no coordinates rather than invented ones. + */ +fun chooseRoverFix(candidates: List): RoverFix? = + candidates.filterNotNull().firstOrNull { it.ageMs <= MAX_ROVER_FIX_AGE_MS } + +/** + * The operator's position right now, for stamping onto a logged QSO. + * + * Deliberately independent of RTOTA trip mode: a contact made at home, at a park, + * or parked at a scenic overlook is worth locating too, and the ADIF that carries + * it is the copy that reaches every other logbook. Trip mode is simply the best + * *source* when it happens to be running. + * + * Never throws and never blocks on the network — every path is either an in-memory + * read or `getLastKnownLocation`, which returns whatever the OS already had. + */ +object RoverPosition { + private const val TAG = "RoverPosition" + + fun current(context: Context?): RoverFix? { + val ctx = context ?: return null + return chooseRoverFix(listOf(liveTripFix(), lastKnownFix(ctx))) + } + + /** The freshest fix the running trip has seen, if a trip is running at all. */ + private fun liveTripFix(): RoverFix? { + val point = RtotaTripManager.latestFix() ?: return null + return RoverFix( + latitude = point.latitude, + longitude = point.longitude, + ageMs = (System.currentTimeMillis() - point.timestampMs).coerceAtLeast(0L), + source = RoverPositionSource.LIVE_FIX, + ) + } + + /** + * The best last known location across providers. + * + * Unlike `MaidenheadGrid.getLocalLocation`, this keeps the fix's timestamp — a + * last known location can be days old, and one from yesterday's drive would + * put today's contact in the wrong state entirely. + */ + @Suppress("TooGenericExceptionCaught") + private fun lastKnownFix(ctx: Context): RoverFix? { + if (!hasLocationPermission(ctx)) return null + return try { + val lm = ctx.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null + val now = System.currentTimeMillis() + lm.getProviders(true) + .mapNotNull { provider -> + @Suppress("MissingPermission") + lm.getLastKnownLocation(provider) + } + .minByOrNull { now - it.time } + ?.let { + RoverFix( + latitude = it.latitude, + longitude = it.longitude, + ageMs = (now - it.time).coerceAtLeast(0L), + source = RoverPositionSource.LAST_KNOWN, + ) + } + } catch (e: Exception) { + Log.d(TAG, "last-known lookup failed: ${e.javaClass.simpleName}") + null + } + } + +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/HighwayClassifier.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/HighwayClassifier.kt new file mode 100644 index 000000000..0fcb0d6b0 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/HighwayClassifier.kt @@ -0,0 +1,158 @@ +package radio.ks3ckc.ft8af.rtota + +import java.util.Locale + +/** + * Turns whatever a reverse geocoder calls the road into the short, stable label + * the trip's `highway` field carries. + * + * The service de-duplicates these into a per-trip "highways traveled" roll-up, + * so the value has to be *canonical*: "I-70", "I 70 E", "Interstate 70" and + * "E I-70" are one road, and a roll-up that lists all four is worse than useless. + * Everything here reduces to one of: + * + * - `I-70` — an Interstate + * - `US-285` — a US route + * - `CO-93` — a state route, prefixed with the state the rover is in + * - [LOCAL_ROAD_LABEL] — any other named road + * - `null` — nothing was resolved, which is not the same as "not a highway" + * + * The null case earns its keep: geocoding needs network, and this app is built + * for the hours it doesn't have any. An unresolved fix must stay unlabelled + * rather than claim the rover was in town, or a trip through a canyon would + * report city driving across the whole dead zone. + */ + +/** + * Wire label for a road that isn't a numbered Interstate, US or state route. + * + * "Local roads" rather than anything city-flavoured: this bucket catches county + * roads and rural two-lanes as readily as town streets, and a road trip spends + * plenty of time on the former. + * + * Deliberately a hardcoded English constant and *not* a string resource: it + * travels to the server and is grouped there, so a phone in a Spanish locale + * must not contribute a second spelling of the same bucket to the roll-up. + */ +const val LOCAL_ROAD_LABEL = "Local roads" + +/** Postal codes that may prefix a state route, e.g. "CO-93". */ +private val STATE_CODES = + setOf( + "AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", + "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD", + "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", + "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", + "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY", + "DC", "PR", "VI", "GU", "AS", "MP", + ) + +// "I-70", "I 70", "Interstate 70" — the trailing \b keeps "I-7" out of "I-70". +private val INTERSTATE = Regex("""\b(?:I|INTERSTATE)[\s-]?(\d{1,3})\b""") + +// "US-285", "US 285", "U.S. 6", "US HIGHWAY 285", "US ROUTE 6". +private val US_ROUTE = Regex("""\bU\.?S\.?[\s-]?(?:HIGHWAY|HWY|ROUTE|RTE|RT)?[\s-]?(\d{1,3})\b""") + +// An explicit postal prefix: "CO-93", "TX 130". Validated against STATE_CODES +// below so "CR 73" (county road) and "SR 520" don't masquerade as states. +private val PREFIXED_STATE_ROUTE = Regex("""\b([A-Z]{2})[\s-](\d{1,3})\b""") + +// "State Highway 7", "State Route 7", "SH 7", "SR 520", "State Road 7". +private val GENERIC_STATE_ROUTE = + Regex("""\b(?:STATE[\s-](?:HIGHWAY|HWY|ROUTE|RTE|ROAD|RD)|SH|SR)[\s-]?(\d{1,3})\b""") + +/** + * Canonicalize [roadName] as reported by the geocoder. + * + * @param stateCode two-letter state the rover is currently in, used to name a + * route the geocoder described only as "State Highway 7". Without it such + * a route falls back to an `SR-` prefix rather than guessing a state. + */ +fun classifyHighway( + roadName: String?, + stateCode: String? = null, +): String? { + val raw = roadName?.trim().orEmpty() + if (raw.isEmpty()) return null + val name = raw.uppercase(Locale.US) + + INTERSTATE.find(name)?.let { return "I-${it.groupValues[1].trimStart('0').ifEmpty { "0" }}" } + US_ROUTE.find(name)?.let { return "US-${it.groupValues[1].trimStart('0').ifEmpty { "0" }}" } + + PREFIXED_STATE_ROUTE.find(name)?.let { m -> + val code = m.groupValues[1] + if (code in STATE_CODES) { + return "$code-${m.groupValues[2].trimStart('0').ifEmpty { "0" }}" + } + } + + GENERIC_STATE_ROUTE.find(name)?.let { m -> + val prefix = stateCode?.trim()?.uppercase(Locale.US)?.takeIf { it in STATE_CODES } ?: "SR" + return "$prefix-${m.groupValues[1].trimStart('0').ifEmpty { "0" }}" + } + + // A named road we recognise as a road, but not as a numbered route. + return LOCAL_ROAD_LABEL +} + +// --------------------------------------------------------------------------- +// Caching policy (pure — the resolver just obeys it) +// --------------------------------------------------------------------------- + +/** Don't re-geocode more often than this, however fast the fixes arrive. */ +const val HIGHWAY_REFRESH_MIN_INTERVAL_MS = 45_000L + +/** …or before the rover has moved this far, however long it has been. */ +const val HIGHWAY_REFRESH_MIN_METERS = 1_500.0 + +/** A label older than this stops being attached to new points. */ +const val HIGHWAY_STALE_AFTER_MS = 10 * 60_000L + +/** …as does one earned this far back down the road. */ +const val HIGHWAY_STALE_AFTER_METERS = 15_000.0 + +/** + * Whether a fresh geocode is worth it. + * + * Fixes arrive about once a second and a geocode is a network round trip, so + * the answer is almost always no. Both a time *and* a distance gate are needed + * for opposite reasons: parked at a fuel stop, distance alone would never + * refresh; at 80 mph, time alone would let the label lag miles behind a + * junction. + */ +fun shouldRefreshHighway( + lastResolvedAtMs: Long, + lastResolvedLat: Double, + lastResolvedLon: Double, + nowMs: Long, + lat: Double, + lon: Double, +): Boolean { + if (lastResolvedAtMs <= 0L) return true + if (nowMs - lastResolvedAtMs >= HIGHWAY_REFRESH_MIN_INTERVAL_MS) return true + return haversineMeters(lastResolvedLat, lastResolvedLon, lat, lon) >= HIGHWAY_REFRESH_MIN_METERS +} + +/** + * The label to attach to a point at ([lat], [lon]), or null when the cached one + * has outlived its usefulness. + * + * Reusing a recent label is the whole reason this works offline-ish: a highway + * persists for tens of miles, so one successful geocode legitimately covers many + * points. But it has to expire — carrying "I-70" through a two-hour dead zone + * would invent a route the rover may have left at the first exit. + */ +fun highwayLabelForPoint( + cached: String?, + cachedAtMs: Long, + cachedLat: Double, + cachedLon: Double, + nowMs: Long, + lat: Double, + lon: Double, +): String? { + if (cached.isNullOrEmpty() || cachedAtMs <= 0L) return null + if (nowMs - cachedAtMs > HIGHWAY_STALE_AFTER_MS) return null + if (haversineMeters(cachedLat, cachedLon, lat, lon) > HIGHWAY_STALE_AFTER_METERS) return null + return cached +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/HighwayResolver.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/HighwayResolver.kt new file mode 100644 index 000000000..37ee46ad0 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/HighwayResolver.kt @@ -0,0 +1,201 @@ +package radio.ks3ckc.ft8af.rtota + +import android.content.Context +import android.location.Address +import android.location.Geocoder +import android.os.Build +import android.util.Log +import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Names the road the rover is on, cheaply and without ever blocking the caller. + * + * Reverse geocoding is a network round trip on most devices, and fixes arrive + * about once a second — so this is a *cache with a refresh policy*, not a + * lookup. [labelFor] answers instantly from the last successful geocode and, at + * most occasionally, kicks off a background refresh. The policy itself lives in + * pure functions ([shouldRefreshHighway], [highwayLabelForPoint]) so the + * interesting decisions are testable without a device. + * + * Offline behaviour is the point rather than an afterthought: a failed geocode + * changes nothing, the previous label keeps applying until it goes stale, and + * after that points simply carry no highway. Nothing here retries, because the + * next fix a second later is a better trigger than any backoff. + */ +class HighwayResolver( + private val context: Context, + private val clock: () -> Long = System::currentTimeMillis, + /** Seam for tests; production uses the platform geocoder. */ + private val geocode: (Double, Double) -> String? = { _, _ -> null }, + private val useSystemGeocoder: Boolean = true, +) { + private companion object { + const val TAG = "HighwayResolver" + + /** + * How long an in-flight geocode may stay in flight before it is written off. + * + * The callback form has no timeout of its own, and a backend that accepts the + * request and never answers would leave [inFlight] set forever — silently + * killing highway resolution for the rest of the trip with no error anywhere. + */ + const val GEOCODE_TIMEOUT_MS = 120_000L + } + + @Volatile + private var cached: String? = null + + @Volatile + private var cachedAtMs = 0L + + @Volatile + private var cachedLat = 0.0 + + @Volatile + private var cachedLon = 0.0 + + /** Last position a refresh was *started* for — throttles independently of success. */ + @Volatile + private var attemptedAtMs = 0L + + @Volatile + private var attemptedLat = 0.0 + + @Volatile + private var attemptedLon = 0.0 + + /** One geocode in flight at a time; a queue would only ever hold stale positions. */ + private val inFlight = AtomicBoolean(false) + + /** + * The label for a fix, and a nudge to refresh if the cache has drifted. + * + * Returns immediately — the refresh it may start lands in the cache for a + * later point, never this one. That lag is fine: a highway is tens of miles + * long and the first mile of it being unlabelled costs nothing. + */ + fun labelFor( + lat: Double, + lon: Double, + stateCode: String?, + ): String? { + val now = clock() + if (shouldRefreshHighway(attemptedAtMs, attemptedLat, attemptedLon, now, lat, lon)) { + refresh(lat, lon, stateCode, now) + } + return highwayLabelForPoint(cached, cachedAtMs, cachedLat, cachedLon, now, lat, lon) + } + + /** Drop everything — a new trip must not inherit the last one's road. */ + fun reset() { + cached = null + cachedAtMs = 0L + attemptedAtMs = 0L + } + + private fun refresh( + lat: Double, + lon: Double, + stateCode: String?, + now: Long, + ) { + if (!inFlight.compareAndSet(false, true)) { + // Reclaim a request that never called back, so one dropped answer doesn't + // cost every remaining mile of the trip its road name. + if (now - attemptedAtMs > GEOCODE_TIMEOUT_MS) { + Log.d(TAG, "abandoning a geocode that never answered") + inFlight.set(false) + } + return + } + attemptedAtMs = now + attemptedLat = lat + attemptedLon = lon + + val onResult: (String?) -> Unit = { roadName -> + try { + classifyHighway(roadName, stateCode)?.let { label -> + cached = label + cachedAtMs = clock() + cachedLat = lat + cachedLon = lon + } + } finally { + inFlight.set(false) + } + } + + if (!useSystemGeocoder) { + // Test seam: resolve synchronously through the injected function. + onResult(runCatching { geocode(lat, lon) }.getOrNull()) + return + } + requestFromSystem(lat, lon, onResult) + } + + /** + * Ask the platform geocoder, on whichever API the device offers. + * + * API 33 added a callback form precisely because the older one blocks on the + * network; below that we have to move to a thread ourselves, since this is + * called from the location callback. + */ + @Suppress("TooGenericExceptionCaught", "DEPRECATION") + private fun requestFromSystem( + lat: Double, + lon: Double, + onResult: (String?) -> Unit, + ) { + if (!Geocoder.isPresent()) { + Log.d(TAG, "no geocoder backend on this device") + onResult(null) + return + } + val geocoder = Geocoder(context, Locale.US) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + geocoder.getFromLocation(lat, lon, 1, object : Geocoder.GeocodeListener { + override fun onGeocode(addresses: MutableList

) { + onResult(roadNameFrom(addresses)) + } + + override fun onError(errorMessage: String?) { + // Almost always "no network" — expected on the road. + Log.d(TAG, "geocode failed: ${errorMessage ?: "?"}") + onResult(null) + } + }) + } else { + Thread({ + val name = + try { + roadNameFrom(geocoder.getFromLocation(lat, lon, 1)) + } catch (e: Exception) { + Log.d(TAG, "geocode failed: ${e.javaClass.simpleName}") + null + } + onResult(name) + }, "rtota-geocode").start() + } + } catch (e: Exception) { + Log.d(TAG, "geocode threw: ${e.javaClass.simpleName}") + onResult(null) + } + } +} + +/** + * Pick the road name out of a geocoder result. + * + * `thoroughfare` is the road proper; `featureName` is checked first only when it + * looks like a route designation, because some backends put "I-70" there and the + * street address in `thoroughfare`. Anything else in `featureName` is a house + * number or POI name and would classify as a local road, so it is ignored. + */ +internal fun roadNameFrom(addresses: List
?): String? { + val address = addresses?.firstOrNull() ?: return null + val feature = address.featureName?.trim() + if (!feature.isNullOrEmpty() && classifyHighway(feature) != LOCAL_ROAD_LABEL) return feature + return address.thoroughfare?.trim()?.takeIf { it.isNotEmpty() } ?: feature?.takeIf { it.isNotEmpty() } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClient.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClient.kt new file mode 100644 index 000000000..03b95dc02 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClient.kt @@ -0,0 +1,277 @@ +package radio.ks3ckc.ft8af.rtota + +import android.util.Log +import com.k1af.ft8af.GeneralVariables +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.FileWriter +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** Non-2xx from rtota.app, carrying the status so callers can tell retryable from fatal. */ +class RtotaHttpException(val httpCode: Int, val body: String) : + Exception("HTTP $httpCode${if (body.isNotBlank()) ": ${body.take(200)}" else ""}") { + /** The `error` field rtota.app puts in every failure body, when present. */ + val serverMessage: String? + get() = + try { + JSONObject(body).optString("error").takeIf { it.isNotEmpty() } + } catch (_: Exception) { + null + } +} + +/** + * True when [error] is worth retrying rather than surfacing: any connectivity + * failure (the normal case on the road), a rate-limit, or a 5xx. A 4xx means the + * request itself is wrong — a bad API key, a trip that isn't ours — and retrying + * it just burns battery against the same rejection. + */ +fun isRetryableRtotaFailure(error: Throwable?): Boolean = + when (error) { + is RtotaHttpException -> error.httpCode == 429 || error.httpCode >= 500 + is IOException -> true + else -> false + } + +/** + * Backoff before retry [attempt] (1-based): 30s, 60s, 2m, 4m, … capped at 15 + * minutes. Long by HTTP standards on purpose — the failure this handles is + * usually "no cell coverage for the next 40 miles", and hammering the radio + * costs battery on a device that is often navigating at the same time. A + * regained network triggers an immediate flush anyway (see [RtotaTripManager]), + * so the backoff only governs the pessimistic case. + */ +fun rtotaBackoffMs(attempt: Int): Long { + val capped = attempt.coerceIn(1, 6) + return minOf(30_000L shl (capped - 1), 15 * 60_000L) +} + +/** + * Talks to the RTOTA API (rtota.app, or a self-hosted origin — see + * [RtotaSettings.baseUrl]). Same house style as + * [radio.ks3ckc.ft8af.pota.PotaClient]: HttpURLConnection only, suspend + * functions on Dispatchers.IO, every call logged into debug.log. + * + * Endpoints used: + * POST /api/operators -> register a callsign, receive an API key + * POST /api/trips -> create a trip + * POST /api/trips/:id/live -> append points + QSOs (idempotent for QSOs) + * POST /api/trips/:id/complete -> finalize + * POST /api/activations -> announce a planned trip + */ +object RtotaClient { + private const val TAG = "RtotaClient" + private const val USER_AGENT = "ft8af-rtota/1.0" + private const val CONNECT_TIMEOUT_MS = 15_000 + private const val READ_TIMEOUT_MS = 30_000 + + /** + * Register a new rover. The server refuses a callsign that already exists + * (409) rather than echoing its key, so a returning user pastes the key from + * their rtota.app dashboard instead. + */ + suspend fun registerOperator( + baseUrl: String, + callsign: String, + name: String? = null, + homeGrid: String? = null, + email: String? = null, + ): Result = + withContext(Dispatchers.IO) { + val body = + JSONObject().apply { + put("callsign", callsign.trim().uppercase(Locale.US)) + name?.takeIf { it.isNotBlank() }?.let { put("name", it.trim()) } + homeGrid?.takeIf { it.isNotBlank() }?.let { put("homeGrid", it.trim()) } + email?.takeIf { it.isNotBlank() }?.let { put("email", it.trim()) } + }.toString() + request("POST", "$baseUrl/api/operators", null, body).mapCatching { resp -> + JSONObject(resp).optString("apiKey").takeIf { it.isNotEmpty() } + ?: throw IllegalStateException("Server returned no apiKey") + } + } + + /** Create a trip. Privacy omitted => the operator's default applies. */ + suspend fun createTrip( + baseUrl: String, + apiKey: String, + name: String, + startTimeMs: Long, + notes: String? = null, + privacy: String? = null, + ): Result = + withContext(Dispatchers.IO) { + val body = + JSONObject().apply { + put("name", name.trim().take(200)) + put("startTime", isoUtc(startTimeMs)) + notes?.takeIf { it.isNotBlank() }?.let { put("notes", it.trim().take(2000)) } + privacy?.takeIf { it.isNotBlank() }?.let { put("privacy", it) } + }.toString() + request("POST", "$baseUrl/api/trips", apiKey, body).mapCatching { resp -> + val o = JSONObject(resp) + val id = + o.optString("id").takeIf { it.isNotEmpty() } + ?: throw IllegalStateException("Server returned no trip id") + RtotaTripHandle(id, o.optString("shareToken").takeIf { it.isNotEmpty() }) + } + } + + /** + * Append a batch to a running trip. Safe to retry: QSOs dedupe on + * `callsign + band + mode + UTC minute`, and breadcrumbs are idempotent on + * (trip, timestamp) — so a response lost after the server committed costs a + * repeated request, not a duplicated route. Callers still only drop a batch on + * a confirmed 2xx: the queue is the sole copy until the server says otherwise. + */ + suspend fun sendLive( + baseUrl: String, + apiKey: String, + tripId: String, + batch: RtotaBatch, + ): Result = + withContext(Dispatchers.IO) { + val body = buildLiveBody(batch.points, batch.qsos) + request("POST", "$baseUrl/api/trips/$tripId/live", apiKey, body).map { resp -> + parseLiveAck(resp) ?: RtotaLiveAck(batch.points.size, batch.qsos.size, 0) + } + } + + /** + * Ask what the server already holds for a trip — the resume handshake. + * + * Worth one request after a long silence (a dead zone measured in hours, a + * process the OS killed, a phone swap): ingestion is idempotent, so the app + * *could* always just re-send its whole backlog, but it has no way to know it + * is only missing the last ten minutes. Asking first turns a multi-megabyte + * re-send over a single bar of signal into a short one. + */ + suspend fun fetchSyncState( + baseUrl: String, + apiKey: String, + tripId: String, + ): Result = + withContext(Dispatchers.IO) { + request("GET", "$baseUrl/api/trips/$tripId/sync-state", apiKey, null).mapCatching { resp -> + parseSyncState(resp) ?: throw IllegalStateException("Unparsable sync-state body") + } + } + + suspend fun completeTrip( + baseUrl: String, + apiKey: String, + tripId: String, + ): Result = + withContext(Dispatchers.IO) { + request("POST", "$baseUrl/api/trips/$tripId/complete", apiKey, "{}").map { } + } + + /** Announce a planned activation so followers see it before departure. */ + suspend fun createActivation( + baseUrl: String, + apiKey: String, + title: String, + startTimeMs: Long, + endTimeMs: Long? = null, + detail: String? = null, + bands: List = emptyList(), + modes: List = emptyList(), + privacy: String? = null, + ): Result = + withContext(Dispatchers.IO) { + val body = + JSONObject().apply { + put("title", title.trim().take(200)) + put("startTime", isoUtc(startTimeMs)) + endTimeMs?.let { put("endTime", isoUtc(it)) } + detail?.takeIf { it.isNotBlank() }?.let { put("detail", it.trim().take(2000)) } + if (bands.isNotEmpty()) put("bands", JSONArray(bands.take(20))) + if (modes.isNotEmpty()) put("modes", JSONArray(modes.take(20))) + privacy?.takeIf { it.isNotBlank() }?.let { put("privacy", it) } + }.toString() + request("POST", "$baseUrl/api/activations", apiKey, body).mapCatching { resp -> + JSONObject(resp).optString("id").takeIf { it.isNotEmpty() } + ?: throw IllegalStateException("Server returned no activation id") + } + } + + /** + * One request. Returns the body on 2xx, a [RtotaHttpException] otherwise, or + * the underlying [IOException] when the network never got there. + * + * The catch is broad on purpose: every failure mode here — DNS, TLS, a proxy + * rewriting the response, a malformed override URL the user typed — must come + * back as a Result the flush loop can classify, never as a thrown exception + * that would take out the upload coroutine mid-trip. + */ + @Suppress("TooGenericExceptionCaught") + private fun request( + method: String, + url: String, + apiKey: String?, + body: String?, + ): Result { + var conn: HttpURLConnection? = null + return try { + conn = + (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = method + connectTimeout = CONNECT_TIMEOUT_MS + readTimeout = READ_TIMEOUT_MS + setRequestProperty("User-Agent", USER_AGENT) + setRequestProperty("Accept", "application/json") + apiKey?.takeIf { it.isNotBlank() }?.let { + setRequestProperty("Authorization", "Bearer $it") + } + if (body != null) { + doOutput = true + setRequestProperty("Content-Type", "application/json; charset=utf-8") + } + } + body?.let { payload -> + conn.outputStream.use { it.write(payload.toByteArray(StandardCharsets.UTF_8)) } + } + val code = conn.responseCode + if (code !in 200..299) { + val err = + conn.errorStream?.bufferedReader(StandardCharsets.UTF_8) + ?.use { it.readText() }.orEmpty() + log("$method $url -> http $code ${err.take(160)}") + return Result.failure(RtotaHttpException(code, err)) + } + val resp = + conn.inputStream?.bufferedReader(StandardCharsets.UTF_8) + ?.use { it.readText() }.orEmpty() + log("$method $url -> $code (${resp.length}B)") + Result.success(resp) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log("$method $url failed: ${e.javaClass.simpleName}: ${e.message ?: "?"}") + Result.failure(e) + } finally { + conn?.disconnect() + } + } + + private fun log(msg: String) { + Log.d(TAG, msg) + try { + val ctx = GeneralVariables.getMainContext() ?: return + val dir = ctx.getExternalFilesDir(null) ?: return + val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(Date()) + FileWriter(File(dir, "debug.log"), true).use { it.append("$ts Rtota: $msg\n") } + } catch (_: Exception) { + } + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCqSession.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCqSession.kt new file mode 100644 index 000000000..2ee1a0251 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCqSession.kt @@ -0,0 +1,129 @@ +package radio.ks3ckc.ft8af.rtota + +import android.util.Log +import com.k1af.ft8af.GeneralVariables +import java.io.File +import java.io.FileWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * The CQ modifier a running trip transmits: `CQ RTOA `. + * + * **Why not "RTOTA".** An FT8 standard message packs the CQ into the 28-bit + * `c28` field, whose reserved range encodes `CQ` plus *one to four letters* (or + * three digits) — that is the whole vocabulary the format has. `POTA` fits at + * exactly four; `RTOTA` is five and simply has no encoding. The app enforces + * this at [com.k1af.ft8af.Ft8Message] (`[0-9]{3}|[A-Z]{1,4}`), where an + * over-long modifier is dropped and the CQ goes out bare — audible, decodable, + * and silently missing the thing that made it a road-trip CQ. + * + * The alternative, free text, is capped at 13 characters and carries no grid at + * all: `CQ RTOTA KS3CKC` is 15 and does not fit, and even where it does fit no + * receiver's CQ filter treats free text as a CQ. So the on-air token is `RTOA` + * and the message stays a real standard CQ, grid included. + */ +const val RTOTA_CQ_MODIFIER = "RTOA" + +/** The modifier grammar an FT8 standard message can actually encode. */ +private val ENCODABLE_MODIFIER = Regex("[0-9]{3}|[A-Z]{1,4}") + +/** + * True when [modifier] survives the trip from `GeneralVariables.toModifier` into + * a transmitted CQ. Anything else is dropped by the message formatter, so a + * caller that cares whether its token will actually go out asks here first. + */ +fun isEncodableCqModifier(modifier: String?): Boolean = + !modifier.isNullOrEmpty() && ENCODABLE_MODIFIER.matches(modifier) + +/** + * What to remember as the operator's own preference when a trip takes the + * modifier over. + * + * Returns "" when [current] is already ours, which is the case that matters: a + * trip re-applying its modifier (a second start, a restore after the process was + * killed) must not record `RTOA` as the thing to put back — that would make the + * road-trip token outlive the road trip, and every later CQ would keep claiming + * a trip that ended. + */ +fun modifierToRemember(current: String?): String = if (current == RTOTA_CQ_MODIFIER) "" else current.orEmpty() + +/** + * The modifier to restore when a trip ends, or null to leave it alone. + * + * Null means "someone else owns this now". A POTA activation started mid-trip + * saved `RTOA` and set `POTA`; if ending the trip blindly wrote the pre-trip + * value back, the operator would be mid-park-activation transmitting a plain CQ, + * and POTA's own end would then restore `RTOA` on top — a token for a trip that + * is over. Deferring to whoever holds the modifier keeps both save/restore + * stacks honest no matter which order they unwind in. + */ +fun modifierAfterTripEnd( + current: String?, + remembered: String, +): String? = if (current == RTOTA_CQ_MODIFIER) remembered else null + +/** + * Owns `GeneralVariables.toModifier` for the duration of a trip, the same way + * [radio.ks3ckc.ft8af.pota.PotaSessionManager] owns it for an activation — save + * what was there, impose ours, put it back on the way out. + * + * Ordering note for callers: this must be applied *after* the persisted config + * has loaded. `DatabaseOpr` assigns `GeneralVariables.toModifier` straight from + * the stored config row as part of that load, so applying earlier both saves a + * value that isn't the operator's real preference and gets overwritten moments + * later. + */ +object RtotaCqSession { + private const val TAG = "RtotaCqSession" + + /** The operator's own modifier, parked here while a trip runs. */ + @Volatile + private var remembered: String = "" + + /** True while this session is the one holding the modifier. */ + val isApplied: Boolean get() = GeneralVariables.toModifier == RTOTA_CQ_MODIFIER + + /** + * Impose `RTOA` for a running trip. Idempotent: calling it again while it is + * already applied changes nothing and, crucially, does not overwrite the + * remembered preference. + */ + @Synchronized + fun apply() { + val current = GeneralVariables.toModifier + if (current == RTOTA_CQ_MODIFIER) { + log("apply — already set") + return + } + remembered = modifierToRemember(current) + GeneralVariables.toModifier = RTOTA_CQ_MODIFIER + log("apply modifier='$RTOTA_CQ_MODIFIER' remembered='$remembered'") + } + + /** Hand the modifier back when the trip ends. */ + @Synchronized + fun release() { + val restored = modifierAfterTripEnd(GeneralVariables.toModifier, remembered) + if (restored == null) { + log("release skipped — modifier is now '${GeneralVariables.toModifier}', not ours") + remembered = "" + return + } + GeneralVariables.toModifier = restored + log("release restored='$restored'") + remembered = "" + } + + private fun log(msg: String) { + Log.d(TAG, msg) + try { + val ctx = GeneralVariables.getMainContext() ?: return + val dir = ctx.getExternalFilesDir(null) ?: return + val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(Date()) + FileWriter(File(dir, "debug.log"), true).use { it.append("$ts RtotaCq: $msg\n") } + } catch (_: Exception) { + } + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaLocationTracker.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaLocationTracker.kt new file mode 100644 index 000000000..889d30aaf --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaLocationTracker.kt @@ -0,0 +1,120 @@ +package radio.ks3ckc.ft8af.rtota + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Bundle +import android.os.Looper +import android.util.Log +import radio.ks3ckc.ft8af.location.hasLocationPermission + +/** + * Subscribes to GPS while a trip is running and hands every fix to + * [RtotaTripManager], which samples them down into route breadcrumbs. + * + * Deliberately *not* built on [com.k1af.ft8af.location.LocationSubscriber]: that + * base class exists for the two toggle-driven, low-cadence consumers (grid + * auto-update every 5 minutes, GPS clock), and its lifecycle is tied to those + * `GeneralVariables` toggles. Trip mode has the opposite shape — high cadence, + * owned by a foreground service, running only between trip start and trip end — + * so it subscribes directly and keeps its own lifecycle. + * + * The subscription asks for a fix every second: the sampler decides what is worth + * keeping, and asking for more than we keep costs nothing extra because the GPS + * chip is already on and fixed while navigating. That wide gap between request + * cadence and stored cadence is exactly what SmartBeaconing needs — it can only + * peg a corner it saw happen, and it can only place a QSO where the rover was if + * a recent fix exists. + */ +class RtotaLocationTracker(context: Context) { + private val appContext = context.applicationContext + + private var locationManager: LocationManager? = null + + @Volatile + private var running = false + + private val listener = + object : LocationListener { + override fun onLocationChanged(location: Location) { + RtotaTripManager.onLocationFix(location) + } + + // The three no-op overrides below are abstract on API < 30, so they must + // stay even though only onLocationChanged carries anything we want. + override fun onStatusChanged( + provider: String?, + status: Int, + extras: Bundle?, + ) {} + + override fun onProviderEnabled(provider: String) {} + + override fun onProviderDisabled(provider: String) {} + } + + fun hasPermission(): Boolean = hasLocationPermission(appContext) + + /** Returns true when at least one provider is feeding us fixes. */ + @SuppressLint("MissingPermission") + @Synchronized + fun start(): Boolean { + if (running) return true + if (!hasPermission()) { + Log.d(TAG, "location permission not granted; tracking not started") + return false + } + val manager = + locationManager + ?: (appContext.getSystemService(Context.LOCATION_SERVICE) as? LocationManager) + ?.also { locationManager = it } + ?: return false + + var subscribed = false + // GPS first (the accurate one while moving); network as a backstop so a + // cold start or a tunnel exit still produces something. + for (provider in listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)) { + try { + if (!manager.isProviderEnabled(provider)) continue + manager.requestLocationUpdates( + provider, + REQUEST_INTERVAL_MS, + REQUEST_DISTANCE_M, + listener, + Looper.getMainLooper(), + ) + subscribed = true + } catch (e: SecurityException) { + Log.e(TAG, "SecurityException on $provider: ${e.message}") + } catch (_: IllegalArgumentException) { + // Provider absent on this device. + } + } + running = subscribed + Log.d(TAG, if (subscribed) "tracking started" else "no location provider available") + return subscribed + } + + @Synchronized + fun stop() { + if (!running) return + try { + locationManager?.removeUpdates(listener) + } catch (_: Exception) { + } + running = false + Log.d(TAG, "tracking stopped") + } + + companion object { + private const val TAG = "RtotaLocationTracker" + + /** Ask for a fix this often; [SmartBeaconSampler] decides what to keep. */ + private const val REQUEST_INTERVAL_MS = 1_000L + private const val REQUEST_DISTANCE_M = 0f + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaModels.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaModels.kt new file mode 100644 index 000000000..ddac5d5f5 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaModels.kt @@ -0,0 +1,394 @@ +package radio.ks3ckc.ft8af.rtota + +import org.json.JSONArray +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * Wire model for RTOTA (Road Trips On The Air) trip mode — the road-going + * companion service at rtota.app. + * + * Everything here is deliberately free of Android types so the payload shapes + * can be unit-tested without a device: the manager collects [TripPoint] / + * [TripQso] values, [RtotaQueue] persists them, and [buildLiveBody] renders the + * exact JSON `POST /api/trips/:id/live` expects. + * + * The server validates every field (zod schemas in lib/api/schemas.ts), so the + * builders below clamp/drop anything out of range rather than let the whole + * batch bounce with a 400 — one bad GPS sample must never strand a trip's + * backlog. Nullable fields are simply omitted when unknown. + */ + +// One GPS breadcrumb along the route. +data class TripPoint( + val timestampMs: Long, + val latitude: Double, + val longitude: Double, + /** Ground speed in mph, or null when the fix carried no speed. */ + val speedMph: Double? = null, + /** Course over ground, 0–360 degrees, or null when unknown/stationary. */ + val headingDeg: Double? = null, + /** Horizontal accuracy in metres, or null when the fix carried none. */ + val accuracyM: Double? = null, + /** U.S. state (or other region) label, when the app has resolved one. */ + val state: String? = null, + /** Highway label such as "I-70", when known. */ + val highway: String? = null, +) + +/** One logged contact, stamped with where the rover was at the time. */ +data class TripQso( + val callsign: String, + val timestampMs: Long, + val band: String? = null, + val mode: String? = null, + val grid: String? = null, + val sentReport: String? = null, + val rcvdReport: String? = null, + val roverLat: Double? = null, + val roverLon: Double? = null, + val state: String? = null, + /** Dial frequency in kHz — powers precise auto-spots on the server. */ + val frequencyKhz: Double? = null, +) + +/** Trip identifiers handed back by `POST /api/trips`. */ +data class RtotaTripHandle(val id: String, val shareToken: String?) + +/** + * What `GET /api/trips/:id/sync-state` says the server already holds — the + * resume handshake for a client that has been out of touch long enough not to + * know what it still owes. + */ +data class RtotaSyncState( + val tripId: String, + /** "active" or "completed" — a completed trip must not be fed any more. */ + val status: String, + val pointCount: Int, + val qsoCount: Int, + /** Dedupe keys of the most recent contacts, newest first. */ + val qsoDedupeKeys: Set, + /** True when the trip holds more keys than the server returned. */ + val truncated: Boolean, +) + +/** What the server reports it did with a live batch. */ +data class RtotaLiveAck( + val pointsInserted: Int, + val qsosInserted: Int, + val duplicates: Int, +) + +// --------------------------------------------------------------------------- +// Formatting helpers (pure) +// --------------------------------------------------------------------------- + +/** + * Render epoch millis as the UTC ISO-8601 instant the API's `isoDate` schema + * accepts (`z.string().datetime()`), e.g. `2026-07-31T14:05:09.000Z`. + * + * A fresh [SimpleDateFormat] per call: the instances are cheap next to an HTTP + * round trip, and a shared one would need synchronising — points arrive from + * the location thread while the flush thread is serialising a batch. + */ +fun isoUtc(epochMs: Long): String { + val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + fmt.timeZone = TimeZone.getTimeZone("UTC") + return fmt.format(Date(epochMs)) +} + +/** + * Parse an ADIF date/time pair (`QSO_DATE` = `yyyyMMdd`, `TIME_ON` = `HHmmss` + * or `HHmm`) as UTC epoch millis, or null when either half is unusable. + * + * QSLRecord stores its timestamps in exactly this split form, so this is the + * bridge from a logged contact back to a real instant. Lenient parsing is off: + * a malformed row must yield null (the caller then falls back to "now") rather + * than silently land the QSO in year 202. + */ +fun parseAdifUtc( + date: String?, + time: String?, +): Long? { + val d = date?.trim().orEmpty() + val t = time?.trim().orEmpty() + if (d.length != 8 || t.length < 4) return null + // ADIF allows HHmm (no seconds); pad so one pattern covers both. + val padded = if (t.length == 4) t + "00" else t.take(6) + return try { + val fmt = SimpleDateFormat("yyyyMMddHHmmss", Locale.US) + fmt.timeZone = TimeZone.getTimeZone("UTC") + fmt.isLenient = false + fmt.parse(d + padded)?.time + } catch (_: Exception) { + null + } +} + +/** + * Format an FT8 signal report for the wire, or null when the value is one of + * QSLRecord's "no report" sentinels (-100 for an unfinished/SWL exchange, -120 + * from imported logs). Mirrors `AdifFormat.formatReport` for real values so a + * live-sent report reads identically to the one in the end-of-trip ADIF. + */ +fun formatReport(report: Int): String? = if (report == -100 || report == -120) null else String.format(Locale.US, "%+03d", report) + +/** + * Dial frequency in Hz → kHz, or null when outside the server's accepted range + * (100 kHz – 10 GHz). A rig that reports 0 while disconnected would otherwise + * fail schema validation for the entire batch. + */ +fun frequencyKhzOrNull(freqHz: Long): Double? { + if (freqHz <= 0L) return null + val khz = freqHz / 1000.0 + return if (khz in 100.0..10_000_000.0) khz else null +} + +/** + * Trim and upper-case a callsign for storage and for the wire. + * + * `Locale.US` is not decoration. The default-locale `uppercase()` is + * locale-sensitive for ASCII: under a Turkish or Azeri locale, "i" upper-cases + * to "İ" (U+0130), so an operator whose phone is set to Turkish would register + * as KİABC, store KİABC, and never match the KIABC the server knows — while + * [RtotaClient.registerOperator] normalizes with `Locale.US` and disagrees with + * the very setting that produced it. Callsigns are ASCII by definition, so the + * locale must be fixed rather than ambient. + */ +fun normalizeCallsign(raw: String): String = raw.trim().uppercase(Locale.US) + +/** + * Clean up a base URL before it is used, repairing the two mistakes that cost a + * whole trip rather than one request. + * + * 1. **A bare host.** "rtota.app" typed into the server field has no scheme, so + * `URL()` throws and every upload fails with a MalformedURLException the + * operator can do nothing with. Assume https. + * 2. **The apex host.** `rtota.app` 308-redirects to `www.rtota.app`, and a 308 + * must not be auto-followed for a POST (RFC 9110 §15.4.9 — the method and + * body have to survive, so a client that can't guarantee that declines). + * Every write here is a POST, so the apex yields a 308 that + * [isRetryableRtotaFailure] correctly classifies as fatal, stranding the trip + * on the phone. Rewriting the host is the difference between a working test + * run and a silent one. + * + * Only the known rtota.app host is rewritten: a self-hosted origin or a dev box + * is left exactly as typed, since nothing here knows how *it* is fronted. + */ +fun normalizeRtotaBaseUrl(raw: String): String { + val trimmed = raw.trim().trimEnd('/') + if (trimmed.isEmpty()) return trimmed + val withScheme = + when { + trimmed.startsWith("http://", ignoreCase = true) -> trimmed + trimmed.startsWith("https://", ignoreCase = true) -> trimmed + else -> "https://$trimmed" + } + // Match the apex host only — not "myrtota.app", and not a path that happens + // to contain the name. + return Regex("^(https?://)rtota\\.app(?=$|[/?#])", RegexOption.IGNORE_CASE) + .replace(withScheme) { "${it.groupValues[1]}www.rtota.app" } +} + +// --------------------------------------------------------------------------- +// JSON encoding / decoding +// --------------------------------------------------------------------------- + +private fun JSONObject.putIfNotNull( + name: String, + value: Any?, +) { + if (value != null) put(name, value) +} + +private fun JSONObject.optStringOrNull(name: String): String? = if (isNull(name)) null else optString(name).takeIf { it.isNotEmpty() } + +private fun JSONObject.optDoubleOrNull(name: String): Double? = if (isNull(name)) null else optDouble(name).takeIf { !it.isNaN() } + +/** + * Encode a point. Out-of-range optional fields are dropped rather than clamped + * where a wrong value would be a lie (a bogus heading), and clamped where the + * value is merely imprecise (a hair over 360 from a wrapping compass). + */ +fun TripPoint.toJson(): JSONObject = + JSONObject().apply { + put("timestamp", isoUtc(timestampMs)) + put("latitude", latitude) + put("longitude", longitude) + putIfNotNull("speedMph", speedMph?.takeIf { it.isFinite() && it >= 0.0 }) + putIfNotNull("headingDeg", headingDeg?.takeIf { it.isFinite() }?.let { ((it % 360.0) + 360.0) % 360.0 }) + putIfNotNull("accuracy", accuracyM?.takeIf { it.isFinite() && it >= 0.0 }) + putIfNotNull("state", state?.takeIf { it.isNotBlank() }?.take(40)) + putIfNotNull("highway", highway?.takeIf { it.isNotBlank() }?.take(40)) + } + +fun TripQso.toJson(): JSONObject = + JSONObject().apply { + put("callsign", callsign.trim().uppercase(Locale.US).take(20)) + put("timestamp", isoUtc(timestampMs)) + putIfNotNull("band", band?.takeIf { it.isNotBlank() }?.take(12)) + putIfNotNull("mode", mode?.takeIf { it.isNotBlank() }?.take(20)) + putIfNotNull("grid", grid?.takeIf { it.isNotBlank() }?.take(12)) + putIfNotNull("sentReport", sentReport?.takeIf { it.isNotBlank() }?.take(12)) + putIfNotNull("rcvdReport", rcvdReport?.takeIf { it.isNotBlank() }?.take(12)) + putIfNotNull("roverLat", roverLat?.takeIf { it.isFinite() && it in -90.0..90.0 }) + putIfNotNull("roverLon", roverLon?.takeIf { it.isFinite() && it in -180.0..180.0 }) + putIfNotNull("state", state?.takeIf { it.isNotBlank() }?.take(40)) + putIfNotNull("frequencyKhz", frequencyKhz?.takeIf { it.isFinite() && it in 100.0..10_000_000.0 }) + } + +/** Rebuild a point from the queue file. Returns null for an unparsable row. */ +fun tripPointFromJson(o: JSONObject): TripPoint? { + val ts = o.optLong("t", 0L) + if (ts <= 0L) return null + val lat = o.optDouble("lat", Double.NaN) + val lon = o.optDouble("lon", Double.NaN) + if (lat.isNaN() || lon.isNaN()) return null + return TripPoint( + timestampMs = ts, + latitude = lat, + longitude = lon, + speedMph = o.optDoubleOrNull("spd"), + headingDeg = o.optDoubleOrNull("hdg"), + accuracyM = o.optDoubleOrNull("acc"), + state = o.optStringOrNull("st"), + highway = o.optStringOrNull("hw"), + ) +} + +/** + * Queue-file encoding. Deliberately *not* [toJson]: the queue stores raw epoch + * millis and short keys so a long dead-zone backlog stays small on disk, and so + * a future change to the wire format can't corrupt already-queued data. + */ +fun TripPoint.toStoredJson(): JSONObject = + JSONObject().apply { + put("t", timestampMs) + put("lat", latitude) + put("lon", longitude) + putIfNotNull("spd", speedMph) + putIfNotNull("hdg", headingDeg) + putIfNotNull("acc", accuracyM) + putIfNotNull("st", state) + putIfNotNull("hw", highway) + } + +fun TripQso.toStoredJson(): JSONObject = + JSONObject().apply { + put("t", timestampMs) + put("call", callsign) + putIfNotNull("band", band) + putIfNotNull("mode", mode) + putIfNotNull("grid", grid) + putIfNotNull("rs", sentReport) + putIfNotNull("rr", rcvdReport) + putIfNotNull("lat", roverLat) + putIfNotNull("lon", roverLon) + putIfNotNull("st", state) + putIfNotNull("khz", frequencyKhz) + } + +fun tripQsoFromJson(o: JSONObject): TripQso? { + val ts = o.optLong("t", 0L) + val call = o.optString("call").trim() + if (ts <= 0L || call.isEmpty()) return null + return TripQso( + callsign = call, + timestampMs = ts, + band = o.optStringOrNull("band"), + mode = o.optStringOrNull("mode"), + grid = o.optStringOrNull("grid"), + sentReport = o.optStringOrNull("rs"), + rcvdReport = o.optStringOrNull("rr"), + roverLat = o.optDoubleOrNull("lat"), + roverLon = o.optDoubleOrNull("lon"), + state = o.optStringOrNull("st"), + frequencyKhz = o.optDoubleOrNull("khz"), + ) +} + +/** The body of one `POST /api/trips/:id/live` call. */ +fun buildLiveBody( + points: List, + qsos: List, +): String = + JSONObject().apply { + if (points.isNotEmpty()) { + put("points", JSONArray().apply { points.forEach { put(it.toJson()) } }) + } + if (qsos.isNotEmpty()) { + put("qsos", JSONArray().apply { qsos.forEach { put(it.toJson()) } }) + } + }.toString() + +/** + * The server's dedupe key for this contact: `CALLSIGN|BAND|MODE|UTC-minute`, + * with an empty field where the value is unknown. + * + * A deliberate mirror of `dedupeKey()` in the service's lib/qso.ts, computed + * from exactly what [toJson] puts on the wire (same trimming, same uppercasing, + * same truncation) so the two never disagree. The asymmetry is the safety net: a + * key the client fails to match merely re-sends a contact the server dedupes + * anyway, whereas a key that matches something it shouldn't would drop a QSO + * that was never delivered. That is why this mirrors the format exactly rather + * than approximating it, and why [RtotaSyncState] is only ever used to *skip* + * sends on an exact hit. + */ +fun TripQso.dedupeKey(): String { + val call = callsign.trim().uppercase(Locale.US).take(20) + val b = band?.takeIf { it.isNotBlank() }?.take(12)?.trim()?.uppercase(Locale.US).orEmpty() + val m = mode?.takeIf { it.isNotBlank() }?.take(20)?.trim()?.uppercase(Locale.US).orEmpty() + // "YYYY-MM-DDTHH:MM" — the ISO instant truncated to the minute, matching the + // server's `toISOString().slice(0, 16)`. + val minute = isoUtc(timestampMs).take(16) + return listOf(call, b, m, minute).joinToString("|") +} + +/** Parse a `GET /api/trips/:id/sync-state` body, or null when it is unusable. */ +fun parseSyncState(body: String?): RtotaSyncState? { + if (body.isNullOrBlank()) return null + return try { + val root = JSONObject(body) + val tripId = root.optString("tripId").takeIf { it.isNotEmpty() } ?: return null + val qsos = root.optJSONObject("qsos") + val keysArray = qsos?.optJSONArray("dedupeKeys") + val keys = mutableSetOf() + if (keysArray != null) { + for (i in 0 until keysArray.length()) { + keysArray.optString(i).takeIf { it.isNotEmpty() }?.let { keys.add(it) } + } + } + RtotaSyncState( + tripId = tripId, + status = root.optString("status"), + pointCount = root.optJSONObject("points")?.optInt("count", 0) ?: 0, + qsoCount = qsos?.optInt("count", 0) ?: 0, + qsoDedupeKeys = keys, + truncated = qsos?.optBoolean("truncated", false) ?: false, + ) + } catch (_: Exception) { + null + } +} + +/** Parse the live endpoint's response into counts for the UI. */ +fun parseLiveAck(body: String?): RtotaLiveAck? { + if (body.isNullOrBlank()) return null + return try { + val root = JSONObject(body) + val points = root.optJSONObject("points") + val qsos = root.optJSONObject("qsos") + RtotaLiveAck( + pointsInserted = points?.optInt("inserted", 0) ?: 0, + qsosInserted = qsos?.optInt("inserted", 0) ?: 0, + duplicates = + (qsos?.optInt("duplicatesExisting", 0) ?: 0) + + (qsos?.optInt("duplicatesInBatch", 0) ?: 0), + ) + } catch (_: Exception) { + null + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQsoMapper.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQsoMapper.kt new file mode 100644 index 000000000..851a02963 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQsoMapper.kt @@ -0,0 +1,88 @@ +package radio.ks3ckc.ft8af.rtota + +import com.k1af.ft8af.log.QSLRecord + +/** + * Turns a logged contact into the trip-mode wire shape. + * + * The mapping is split in two so it can be tested without constructing a + * QSLRecord (whose constructors reach into GeneralVariables and the rig layer): + * [buildTripQso] is pure and takes the fields as plain values, and + * [tripQsoFromRecord] is the thin adapter the QSO save path calls. + */ +object RtotaQsoMapper { + /** + * @param roverLat/[roverLon] where the rover was — the last accepted GPS + * breadcrumb. Omitted when tracking hasn't produced a fix yet; the + * server then still records the contact, just without a map position. + * @param nowMs fallback timestamp for a record with unparsable date/time. + */ + @Suppress("LongParameterList") + fun buildTripQso( + toCallsign: String?, + qsoDate: String?, + timeOn: String?, + qsoDateOff: String?, + timeOff: String?, + band: String?, + mode: String?, + toGrid: String?, + sendReport: Int, + receivedReport: Int, + bandFreqHz: Long, + roverLat: Double?, + roverLon: Double?, + state: String?, + nowMs: Long, + ): TripQso? { + val call = toCallsign?.trim().orEmpty() + if (call.isEmpty()) return null + // Prefer TIME_ON (start of the exchange) — it is what the server's dedupe + // key rounds to the minute, so the live copy and the end-of-trip ADIF + // copy of the same QSO must agree on it. + val ts = + parseAdifUtc(qsoDate, timeOn) + ?: parseAdifUtc(qsoDateOff, timeOff) + ?: nowMs + return TripQso( + callsign = call, + timestampMs = ts, + band = band?.trim()?.takeIf { it.isNotEmpty() }, + mode = mode?.trim()?.takeIf { it.isNotEmpty() }, + grid = toGrid?.trim()?.takeIf { it.isNotEmpty() }, + // QSLRecord's sendReport is the report *we sent* the other station, + // receivedReport the one they sent us — matching the API's naming. + sentReport = formatReport(sendReport), + rcvdReport = formatReport(receivedReport), + roverLat = roverLat, + roverLon = roverLon, + state = state?.trim()?.takeIf { it.isNotEmpty() }, + frequencyKhz = frequencyKhzOrNull(bandFreqHz), + ) + } + + fun tripQsoFromRecord( + record: QSLRecord, + roverLat: Double?, + roverLon: Double?, + state: String?, + nowMs: Long = System.currentTimeMillis(), + ): TripQso? = + buildTripQso( + toCallsign = record.getToCallsign(), + qsoDate = record.getQso_date(), + timeOn = record.getTime_on(), + qsoDateOff = record.getQso_date_off(), + timeOff = record.getTime_off(), + band = record.getBandLength(), + mode = record.getMode(), + toGrid = record.getToMaidenGrid(), + sendReport = record.getSendReport(), + receivedReport = record.getReceivedReport(), + bandFreqHz = record.getBandFreq(), + roverLat = roverLat, + roverLon = roverLon, + state = state, + nowMs = nowMs, + ) +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQueue.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQueue.kt new file mode 100644 index 000000000..359f3dc01 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQueue.kt @@ -0,0 +1,180 @@ +package radio.ks3ckc.ft8af.rtota + +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** One batch handed to the uploader, plus how much of the queue it covers. */ +data class RtotaBatch(val points: List, val qsos: List) { + val isEmpty: Boolean get() = points.isEmpty() && qsos.isEmpty() + val size: Int get() = points.size + qsos.size +} + +/** + * The outbox for trip mode: breadcrumbs and contacts wait here until they reach + * rtota.app, and survive a process death while they wait. + * + * Roving means hours out of coverage. Everything the app records goes into this + * queue first and is only dropped once the server has acknowledged it, so a + * canyon, a dead battery, or an OS-initiated kill costs nothing but time. The + * live endpoint dedupes QSOs by `callsign + band + mode + UTC minute`, so a + * re-send after an ambiguous failure is always safe. + * + * Writes go straight to disk on every add. That is more I/O than strictly + * needed, but a breadcrumb the app "has" only in RAM is a breadcrumb it loses in + * exactly the situation this queue exists for. The file is small (a full day of + * driving at the default cadence is a few hundred KB). + * + * [file] may be null in tests that only exercise the in-memory behaviour. + */ +class RtotaQueue(private val file: File?) { + private val points = ArrayList() + private val qsos = ArrayList() + + /** + * Caps. Points are the disposable half: at the default cadence 20,000 of + * them is roughly a week of continuous driving, and past that the oldest go + * first so the queue can't grow without bound on a phone that never regains + * signal. QSOs get a cap too, but a far more generous one — a contact is the + * point of the whole exercise, and the end-of-trip ADIF upload is the only + * other copy. + */ + companion object { + const val MAX_POINTS = 20_000 + const val MAX_QSOS = 20_000 + + /** Batch size per request — keeps a single POST modest over 1 bar of LTE. */ + const val BATCH_POINTS = 250 + const val BATCH_QSOS = 250 + } + + @Synchronized + fun addPoint(point: TripPoint) { + points.add(point) + trim() + persist() + } + + @Synchronized + fun addQso(qso: TripQso) { + qsos.add(qso) + trim() + persist() + } + + @Synchronized + fun pointCount(): Int = points.size + + @Synchronized + fun qsoCount(): Int = qsos.size + + @Synchronized + fun isEmpty(): Boolean = points.isEmpty() && qsos.isEmpty() + + /** The next chunk to upload, oldest first. Does not remove anything. */ + @Synchronized + fun peekBatch( + maxPoints: Int = BATCH_POINTS, + maxQsos: Int = BATCH_QSOS, + ): RtotaBatch = + RtotaBatch( + points = points.take(maxPoints), + qsos = qsos.take(maxQsos), + ) + + /** + * Drop a batch the server accepted. Counts, not identities: the queue is + * append-only at the tail and only ever drained from the head, so removing + * the first N is exactly what was sent — while an add that raced in during + * the upload sits safely behind it. + */ + @Synchronized + fun commit(batch: RtotaBatch) { + repeat(minOf(batch.points.size, points.size)) { points.removeAt(0) } + repeat(minOf(batch.qsos.size, qsos.size)) { qsos.removeAt(0) } + persist() + } + + /** + * Drop queued contacts the server has already stored, identified by the + * dedupe keys a sync-state handshake returned. Returns how many were removed. + * + * Only ever called with keys the server itself reported, and matched on the + * exact format it stores (see [dedupeKey]) — a near-miss re-sends a contact + * that then dedupes server-side, which costs a few bytes, while a false match + * would silently discard a QSO that never arrived. Points are left alone: + * they carry no key, and re-sending them is already idempotent on + * (trip, timestamp). + */ + @Synchronized + fun pruneAcknowledgedQsos(serverKeys: Set): Int { + if (serverKeys.isEmpty() || qsos.isEmpty()) return 0 + val before = qsos.size + qsos.retainAll { it.dedupeKey() !in serverKeys } + val removed = before - qsos.size + if (removed > 0) persist() + return removed + } + + @Synchronized + fun clear() { + points.clear() + qsos.clear() + persist() + } + + /** Load a previously persisted queue. Silently starts empty on a bad file. */ + @Synchronized + fun load() { + val f = file ?: return + if (!f.exists()) return + try { + val root = JSONObject(f.readText()) + points.clear() + qsos.clear() + root.optJSONArray("points")?.let { arr -> + for (i in 0 until arr.length()) { + arr.optJSONObject(i)?.let { tripPointFromJson(it) }?.let { points.add(it) } + } + } + root.optJSONArray("qsos")?.let { arr -> + for (i in 0 until arr.length()) { + arr.optJSONObject(i)?.let { tripQsoFromJson(it) }?.let { qsos.add(it) } + } + } + trim() + } catch (_: Exception) { + // A half-written file (killed mid-persist) is worth less than a clean + // start; the ADIF upload at the end of the trip is the safety net. + points.clear() + qsos.clear() + } + } + + private fun trim() { + while (points.size > MAX_POINTS) points.removeAt(0) + while (qsos.size > MAX_QSOS) qsos.removeAt(0) + } + + private fun persist() { + val f = file ?: return + try { + val root = + JSONObject().apply { + put("points", JSONArray().apply { points.forEach { put(it.toStoredJson()) } }) + put("qsos", JSONArray().apply { qsos.forEach { put(it.toStoredJson()) } }) + } + // Write-then-rename: a kill mid-write leaves the previous good file in + // place instead of a truncated one. + val tmp = File(f.parentFile, f.name + ".tmp") + tmp.writeText(root.toString()) + if (!tmp.renameTo(f)) { + f.writeText(root.toString()) + tmp.delete() + } + } catch (_: Exception) { + // Out of space / no permission — keep going in memory rather than + // taking down the trip. + } + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaSettings.kt new file mode 100644 index 000000000..6e803f418 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaSettings.kt @@ -0,0 +1,211 @@ +package radio.ks3ckc.ft8af.rtota + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.k1af.ft8af.GeneralVariables + +/** + * Persistent configuration for RTOTA trip mode. + * + * Kept in its own Keystore-backed [EncryptedSharedPreferences] file rather than + * the app's `config` table because the API key is a standing upload credential + * for the operator's account (same reasoning as [radio.ks3ckc.ft8af.pota.PotaAuth]'s + * refresh token) — and because the settings-backup export copies the config + * table verbatim, which would put the key in a shareable file. + * + * The in-flight trip lives here too, not just in memory: a phone that reboots or + * gets its process killed 300 miles into a trip must come back up still attached + * to the same trip id, with its queued breadcrumbs intact. + */ +object RtotaSettings { + private const val PREFS = "rtota_prefs" + + private const val KEY_ENABLED = "enabled" + private const val KEY_BASE_URL = "baseUrl" + private const val KEY_API_KEY = "apiKey" + private const val KEY_CALLSIGN = "callsign" + private const val KEY_DEFAULT_PRIVACY = "defaultPrivacy" + private const val KEY_TRIP_ID = "tripId" + private const val KEY_TRIP_NAME = "tripName" + private const val KEY_TRIP_STARTED_MS = "tripStartedMs" + private const val KEY_TRIP_SHARE_TOKEN = "tripShareToken" + private const val KEY_TRIP_PRIVACY = "tripPrivacy" + private const val KEY_TRIP_PENDING_CREATE = "tripPendingCreate" + private const val KEY_TRIP_PENDING_COMPLETE = "tripPendingComplete" + + /** + * The hosted service; overridable for self-hosting or a dev box. + * + * The `www.` host is deliberate and load-bearing. The apex `rtota.app` answers + * every request with a 308 to `www.`, and a 308 is precisely the redirect no + * HTTP client may follow for a POST without being told to (RFC 9110: the + * method and body must be preserved, so clients decline rather than guess). + * Every write this app makes is a POST, so pointing at the apex turns trip + * creation into a permanent, non-retryable failure. See [normalizeRtotaBaseUrl], + * which repairs an apex URL typed into the server-override field for the same + * reason. + */ + const val DEFAULT_BASE_URL = "https://www.rtota.app" + + val PRIVACY_LEVELS = listOf("public", "delayed", "followers", "private") + + @Volatile + private var cached: SharedPreferences? = null + + private fun prefs(ctx: Context): SharedPreferences { + cached?.let { return it } + return synchronized(this) { + cached ?: build(ctx.applicationContext).also { cached = it } + } + } + + private fun build(ctx: Context): SharedPreferences = + try { + createEncrypted(ctx) + } catch (_: Exception) { + // A corrupted keyset (app restored onto a device with no matching key) + // makes create() throw forever. Drop the file and rebuild once — the + // user re-enters their API key, which beats a permanently broken screen. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + ctx.deleteSharedPreferences(PREFS) + } else { + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().commit() + } + createEncrypted(ctx) + } + + private fun createEncrypted(ctx: Context): SharedPreferences { + val masterKey = + MasterKey.Builder(ctx) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + return EncryptedSharedPreferences.create( + ctx, + PREFS, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + /** + * Every accessor resolves the context lazily through GeneralVariables so the + * object stays callable from the QSO save path, which has no Context of its + * own. Before the app finishes starting this returns null and reads fall back + * to defaults — deliberately, so nothing here can crash a cold start. + */ + private fun prefsOrNull(): SharedPreferences? = GeneralVariables.getMainContext()?.let { prefs(it) } + + private fun readString( + key: String, + fallback: String, + ): String = prefsOrNull()?.getString(key, fallback) ?: fallback + + private fun writeString( + key: String, + value: String?, + ) { + prefsOrNull()?.edit()?.apply { + if (value.isNullOrEmpty()) remove(key) else putString(key, value) + }?.apply() + } + + // -- Account ------------------------------------------------------------ + + /** Master switch: with this off, trip mode never touches GPS or the network. */ + var enabled: Boolean + get() = prefsOrNull()?.getBoolean(KEY_ENABLED, false) ?: false + set(value) { + prefsOrNull()?.edit()?.putBoolean(KEY_ENABLED, value)?.apply() + } + + /** + * Service origin, normalized so callers can append paths. Normalizing on + * *read* rather than only on write is what rescues the value an earlier build + * already persisted — an install that stored the apex `https://rtota.app` + * keeps working after an update instead of 308-ing forever. + */ + var baseUrl: String + get() = + normalizeRtotaBaseUrl(readString(KEY_BASE_URL, DEFAULT_BASE_URL)) + .ifEmpty { DEFAULT_BASE_URL } + set(value) = writeString(KEY_BASE_URL, normalizeRtotaBaseUrl(value)) + + var apiKey: String + get() = readString(KEY_API_KEY, "") + set(value) = writeString(KEY_API_KEY, value.trim()) + + /** The registered rover callsign; defaults to the operator's own callsign. */ + var callsign: String + get() = readString(KEY_CALLSIGN, "").ifEmpty { GeneralVariables.myCallsign.orEmpty() } + set(value) = writeString(KEY_CALLSIGN, normalizeCallsign(value)) + + val isConfigured: Boolean get() = apiKey.isNotBlank() + + /** Privacy preselected in the start-trip sheet; blank = let the server decide. */ + var defaultPrivacy: String + get() = readString(KEY_DEFAULT_PRIVACY, "") + set(value) = writeString(KEY_DEFAULT_PRIVACY, value) + + // -- In-flight trip ----------------------------------------------------- + + /** Server trip id, or empty when no trip is running / it hasn't been created yet. */ + var tripId: String + get() = readString(KEY_TRIP_ID, "") + set(value) = writeString(KEY_TRIP_ID, value) + + var tripName: String + get() = readString(KEY_TRIP_NAME, "") + set(value) = writeString(KEY_TRIP_NAME, value) + + var tripShareToken: String + get() = readString(KEY_TRIP_SHARE_TOKEN, "") + set(value) = writeString(KEY_TRIP_SHARE_TOKEN, value) + + var tripPrivacy: String + get() = readString(KEY_TRIP_PRIVACY, "") + set(value) = writeString(KEY_TRIP_PRIVACY, value) + + var tripStartedMs: Long + get() = prefsOrNull()?.getLong(KEY_TRIP_STARTED_MS, 0L) ?: 0L + set(value) { + prefsOrNull()?.edit()?.putLong(KEY_TRIP_STARTED_MS, value)?.apply() + } + + /** + * True when the user started a trip out of coverage: tracking runs and the + * queue fills locally, and the flush loop keeps retrying `POST /api/trips` + * until it lands. Without this, starting a trip in a dead zone (which is + * where roving starts more often than not) would simply fail. + */ + var tripPendingCreate: Boolean + get() = prefsOrNull()?.getBoolean(KEY_TRIP_PENDING_CREATE, false) ?: false + set(value) { + prefsOrNull()?.edit()?.putBoolean(KEY_TRIP_PENDING_CREATE, value)?.apply() + } + + /** Mirror of the above for the end of the trip: complete once we're back online. */ + var tripPendingComplete: Boolean + get() = prefsOrNull()?.getBoolean(KEY_TRIP_PENDING_COMPLETE, false) ?: false + set(value) { + prefsOrNull()?.edit()?.putBoolean(KEY_TRIP_PENDING_COMPLETE, value)?.apply() + } + + /** True while a trip is running — created on the server or still pending. */ + val hasActiveTrip: Boolean get() = tripId.isNotEmpty() || tripPendingCreate + + fun clearTrip() { + prefsOrNull()?.edit() + ?.remove(KEY_TRIP_ID) + ?.remove(KEY_TRIP_NAME) + ?.remove(KEY_TRIP_SHARE_TOKEN) + ?.remove(KEY_TRIP_PRIVACY) + ?.remove(KEY_TRIP_STARTED_MS) + ?.remove(KEY_TRIP_PENDING_CREATE) + ?.remove(KEY_TRIP_PENDING_COMPLETE) + ?.apply() + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaTripManager.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaTripManager.kt new file mode 100644 index 000000000..dc1fcb47c --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaTripManager.kt @@ -0,0 +1,660 @@ +package radio.ks3ckc.ft8af.rtota + +import android.content.Context +import android.location.Location +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.util.Log +import com.google.android.gms.maps.model.LatLng +import com.k1af.ft8af.GeneralVariables +import com.k1af.ft8af.log.QSLRecord +import com.k1af.ft8af.maidenhead.MaidenheadGrid +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import radio.ks3ckc.ft8af.ui.decode.UsStateLookup +import java.io.File +import java.io.FileWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** Everything the trip UI (and the service notification) renders. */ +data class RtotaTripState( + val active: Boolean = false, + val tripId: String = "", + val tripName: String = "", + val startedMs: Long = 0L, + val shareToken: String = "", + /** True while the trip exists only on the phone — created out of coverage. */ + val pendingCreate: Boolean = false, + val pendingPoints: Int = 0, + val pendingQsos: Int = 0, + val sentPoints: Int = 0, + val sentQsos: Int = 0, + val miles: Double = 0.0, + val lastFixMs: Long = 0L, + val lastUploadMs: Long = 0L, + val uploading: Boolean = false, + /** Which SmartBeaconing rule kept the most recent route point. */ + val lastBeaconReason: BeaconReason? = null, + /** True while the sampler considers the rover stopped (no points are recorded). */ + val parked: Boolean = false, + /** Last upload failure, cleared by the next success. Shown verbatim in the UI. */ + val lastError: String? = null, +) + +/** + * Trip mode: streams the rover's route and contacts to rtota.app while driving. + * + * Responsibilities, in the order they matter on the road: + * 1. Record. Every accepted GPS breadcrumb and every logged QSO lands in + * [RtotaQueue] (on disk) before anything is attempted over the network. + * 2. Deliver. A single-flight flush drains the queue in batches whenever there + * is something to send, backing off on failure and flushing immediately when + * connectivity returns. + * 3. Survive. Trip identity lives in [RtotaSettings], so a reboot or a killed + * process resumes the same trip rather than orphaning it. + * + * A trip can be started *and* ended with no coverage at all: creation and + * completion are both deferred flags that the flush loop resolves later. + * + * The object is a singleton because its two producers — the location tracker and + * the QSO save path deep in DatabaseOpr — have no shared owner to hang it off. + */ +object RtotaTripManager { + private const val TAG = "RtotaTripManager" + private const val QUEUE_FILE = "rtota_queue.json" + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val flushLock = Mutex() + + private val _state = MutableStateFlow(RtotaTripState()) + val state: StateFlow = _state.asStateFlow() + + @Volatile + private var appContext: Context? = null + + @Volatile + private var queue: RtotaQueue? = null + + /** Names the road for each breadcrumb; null until [init] has a context. */ + @Volatile + private var highwayResolver: HighwayResolver? = null + + private var sampler = SmartBeaconSampler() + + /** + * The freshest fix seen, kept or not. QSOs are stamped from this rather than + * from the last *beacon*: on an interstate the last kept point can be half a + * minute and half a mile back, and a contact belongs where the rover actually + * was when it happened. + */ + @Volatile + private var lastRawFix: TripPoint? = null + + /** + * The freshest GPS fix this trip has seen, or null when no trip is running. + * + * Exposed for [radio.ks3ckc.ft8af.location.RoverPosition], which stamps every + * logged QSO with a position whether or not a trip is running — while a trip *is* + * running, this is by far the best source available, since it is a live fix the + * app is already paying for. + */ + fun latestFix(): TripPoint? = if (RtotaSettings.hasActiveTrip) lastRawFix else null + + /** Consecutive failed flushes — drives [rtotaBackoffMs]. */ + @Volatile + private var failedAttempts = 0 + + /** + * Set when a trip is resumed from disk, cleared once the server has told us + * what it already holds. Only a *resumed* trip needs asking: a trip this + * process started knows exactly what it has sent. + */ + @Volatile + private var resumeHandshakePending = false + + @Volatile + private var retryJob: Job? = null + + private var connectivityRegistered = false + + private val networkCallback = + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + // Coming out of a dead zone is the single best moment to flush. + requestFlush("network-available") + } + } + + /** + * Bind to the application context and restore any interrupted trip. Safe to + * call more than once; called from the activity once the app is up. + */ + fun init(context: Context) { + val ctx = context.applicationContext + if (appContext == null) { + appContext = ctx + queue = RtotaQueue(File(ctx.filesDir, QUEUE_FILE)).also { it.load() } + highwayResolver = HighwayResolver(ctx) + } + registerConnectivity(ctx) + restore() + } + + /** + * Re-impose the trip's CQ modifier once the persisted config has loaded. + * + * Separate from [init] on purpose. `init` runs early in activity startup, + * while `DatabaseOpr` assigns `GeneralVariables.toModifier` from the stored + * config row later — so applying at init time would both bank the wrong + * "operator's preference" and then be overwritten by the config load anyway. + * The caller invokes this from the config-loaded callback instead. + */ + fun onConfigLoaded() { + if (!RtotaSettings.enabled || !RtotaSettings.hasActiveTrip) return + RtotaCqSession.apply() + } + + /** Re-derive UI state from what was persisted, and resume delivery. */ + private fun restore() { + if (!RtotaSettings.enabled) return + if (!RtotaSettings.hasActiveTrip) { + publish() + return + } + // A resumed trip starts the sampler fresh: the first fix after a restart + // becomes a FIRST beacon, which is right — the gap while the app was dead + // is real, and pretending to continue from a stale point would draw a + // straight line across wherever the phone actually went. + sampler = SmartBeaconSampler() + highwayResolver?.reset() + lastRawFix = null + // Ask the server what it has before re-sending a queue we can't account for. + resumeHandshakePending = RtotaSettings.tripId.isNotEmpty() + _state.value = + _state.value.copy( + active = true, + tripId = RtotaSettings.tripId, + tripName = RtotaSettings.tripName, + startedMs = RtotaSettings.tripStartedMs, + shareToken = RtotaSettings.tripShareToken, + pendingCreate = RtotaSettings.tripPendingCreate, + ) + publish() + log( + "restored trip id=${RtotaSettings.tripId.ifEmpty { "(pending)" }} " + + "queued=${queue?.pointCount() ?: 0}pts/${queue?.qsoCount() ?: 0}qsos", + ) + startTracking() + requestFlush("restore") + } + + // ----------------------------------------------------------------------- + // Trip lifecycle + // ----------------------------------------------------------------------- + + /** + * Begin a trip. Returns immediately — creation on the server happens in the + * background and is retried until it lands, so the rover can pull out of a + * driveway with no signal and still have a complete route. + */ + fun startTrip( + name: String, + privacy: String?, + notes: String? = null, + ) { + if (RtotaSettings.hasActiveTrip) { + log("startTrip ignored — a trip is already running") + return + } + val startedMs = System.currentTimeMillis() + RtotaSettings.tripName = name.trim().ifEmpty { defaultTripName(startedMs) } + RtotaSettings.tripStartedMs = startedMs + RtotaSettings.tripPrivacy = privacy.orEmpty() + RtotaSettings.tripPendingCreate = true + RtotaSettings.tripPendingComplete = false + RtotaSettings.tripId = "" + RtotaSettings.tripShareToken = "" + + sampler = SmartBeaconSampler() + highwayResolver?.reset() + lastRawFix = null + // A trip this process started has nothing to reconcile — it knows what it sent. + resumeHandshakePending = false + queue?.clear() + _state.value = + RtotaTripState( + active = true, + tripName = RtotaSettings.tripName, + startedMs = startedMs, + pendingCreate = true, + ) + log("startTrip '${RtotaSettings.tripName}' privacy=${privacy ?: "(default)"}") + // From here every generated CQ goes out as "CQ RTOA ". + RtotaCqSession.apply() + startTracking() + // The trip notes ride along with the deferred create. + pendingNotes = notes + requestFlush("start-trip") + } + + @Volatile + private var pendingNotes: String? = null + + /** + * End the trip: stop tracking, flush what's left, then finalize on the + * server. Out of coverage this leaves a "complete when possible" flag, and + * the flush loop finishes the job once the phone is back online. + */ + fun endTrip() { + if (!RtotaSettings.hasActiveTrip) return + // Pin the last known position before tracking stops, so the route ends + // where the trip ended rather than at the last beacon behind it. + lastRawFix?.let { fix -> sampler.anchorForQso(fix)?.let { recordPoint(it) } } + log( + "endTrip '${RtotaSettings.tripName}' queued=${queue?.pointCount() ?: 0}pts/" + + "${queue?.qsoCount() ?: 0}qsos", + ) + RtotaSettings.tripPendingComplete = true + // Released now, not when the server finally acks the completion: out of + // coverage that ack can be hours away, and the operator is off the road + // and off the trip the moment they say so. + RtotaCqSession.release() + stopTracking() + requestFlush("end-trip") + } + + /** + * Give up on a trip that can't be delivered (bad key, deleted trip). Drops + * the local queue and forgets the trip — the end-of-trip ADIF upload from the + * logbook remains the way to publish it. + */ + fun abandonTrip() { + RtotaCqSession.release() + stopTracking() + queue?.clear() + RtotaSettings.clearTrip() + sampler.reset() + lastRawFix = null + resumeHandshakePending = false + _state.value = RtotaTripState() + log("abandonTrip — local queue dropped") + } + + private fun defaultTripName(startedMs: Long): String { + val call = RtotaSettings.callsign.ifBlank { GeneralVariables.myCallsign.orEmpty() } + val day = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(startedMs)) + return listOf(call, day).filter { it.isNotBlank() }.joinToString(" ") + } + + // ----------------------------------------------------------------------- + // Producers + // ----------------------------------------------------------------------- + + /** + * A GPS fix from [RtotaLocationTracker], arriving about once a second. + * [SmartBeaconSampler] decides which ones become route points; the rest still + * update [lastRawFix] so a QSO can be placed precisely. + */ + fun onLocationFix(location: Location) { + if (!RtotaSettings.hasActiveTrip) return + val state = stateForLatLon(location.latitude, location.longitude) + val candidate = + TripPoint( + timestampMs = if (location.time > 0) location.time else System.currentTimeMillis(), + latitude = location.latitude, + longitude = location.longitude, + // Android reports m/s; the API wants mph. + speedMph = if (location.hasSpeed()) location.speed * MPS_TO_MPH else null, + headingDeg = if (location.hasBearing()) location.bearing.toDouble() else null, + accuracyM = if (location.hasAccuracy()) location.accuracy.toDouble() else null, + state = state, + // Answers from cache and refreshes in the background — never blocks + // this callback, and yields null rather than a guess when offline. + highway = highwayResolver?.labelFor(location.latitude, location.longitude, state), + ) + lastRawFix = candidate + + val decision = + sampler.offer(candidate) ?: run { + // Not a beacon — but a long stretch of not-a-beacon is how the sampler + // learns the rover has parked, so let it see the fix either way. + sampler.noteStationary(candidate) + val wasParked = _state.value.parked + _state.value = + _state.value.copy( + lastFixMs = candidate.timestampMs, + parked = sampler.isParked, + ) + // The notification says "parked", and this is the only path that can + // ever set it: once parked, no fix is kept, so recordPoint's publish() + // never runs and the notification would claim the rover is still + // rolling for as long as it sits there. Republish on the transition + // only — every fix would rebuild the notification once a second for + // the whole trip, since updateNotification does no throttling of its own. + if (sampler.isParked != wasParked) publish() + return + } + recordPoint(decision) + requestFlush("point") + } + + /** Queue a kept breadcrumb and reflect it in the UI/notification. */ + private fun recordPoint(decision: BeaconDecision) { + queue?.addPoint(decision.point) + _state.value = + _state.value.copy( + lastFixMs = decision.point.timestampMs, + miles = sampler.traveledMeters / METERS_PER_MILE, + lastBeaconReason = decision.reason, + parked = sampler.isParked, + ) + publish() + } + + /** + * A QSO was just written to the log. Called from `DatabaseOpr.doInsertQSLData` + * for on-air and manually-logged contacts (bulk ADIF imports are excluded by + * the caller — a whole imported logbook is not part of today's drive). + * + * Two things happen, and the second is the reason the map looks right: the + * contact is stamped with the *current* position ([lastRawFix]), and that + * position is also forced into the route as a breadcrumb. Without the anchor, + * a QSO logged mid-interstate-leg would be plotted up to half a mile off the + * drawn line — the line only has vertices where SmartBeaconing put them. + * With it, every contact sits exactly on the route it was made from. + * + * Never throws: this runs inside the app's log-write path, where an exception + * would cost the operator the QSO itself. + */ + @JvmStatic + @Suppress("TooGenericExceptionCaught") // see the "never throws" note above + fun onQsoLogged(record: QSLRecord?) { + try { + if (record == null || !RtotaSettings.enabled || !RtotaSettings.hasActiveTrip) return + val here = lastRawFix ?: sampler.lastAccepted + // The record was stamped with a position moments ago by the QSO save path + // (RoverPosition), which runs whether or not a trip is active. Preferring it + // guarantees the live copy of this contact and the one in the end-of-trip + // ADIF report the identical coordinate — they dedupe into one row server-side, + // and two spellings of "where it happened" would be settled by arrival order. + val qso = + RtotaQsoMapper.tripQsoFromRecord( + record = record, + roverLat = record.myLat ?: here?.latitude, + roverLon = record.myLon ?: here?.longitude, + state = here?.state, + ) ?: return + queue?.addQso(qso) + // Pin the route to where the contact happened. + here?.let { fix -> sampler.anchorForQso(fix)?.let { recordPoint(it) } } + publish() + requestFlush("qso") + log("queued QSO ${qso.callsign} ${qso.band ?: "?"} ${qso.mode ?: "?"}") + } catch (e: Exception) { + log("onQsoLogged failed: ${e.javaClass.simpleName}: ${e.message ?: "?"}") + } + } + + /** + * Coarse U.S. state for a position, via the same 4-character-grid table the + * decode list uses for Worked All States. A grid square straddles state lines + * near a border, so this is a label for "which states did this trip touch", + * not a legal determination — good enough for the trip's states-visited roll-up. + */ + private fun stateForLatLon( + lat: Double, + lon: Double, + ): String? { + val ctx = appContext ?: return null + return try { + val grid = MaidenheadGrid.getGridSquare(LatLng(lat, lon)) + UsStateLookup.stateFromGrid(ctx, grid) + } catch (_: Exception) { + null + } + } + + // ----------------------------------------------------------------------- + // Delivery + // ----------------------------------------------------------------------- + + /** Ask for a flush. Cheap and safe to call from any thread, as often as you like. */ + fun requestFlush(reason: String) { + if (!RtotaSettings.enabled || !RtotaSettings.isConfigured) return + scope.launch { flush(reason) } + } + + /** + * Drain the queue. Serialized by [flushLock] so a burst of triggers (a QSO + * logged the same second a network appears) collapses into one pass. + */ + private suspend fun flush(reason: String) { + val q = queue ?: return + if (!RtotaSettings.hasActiveTrip) return + val apiKey = RtotaSettings.apiKey + if (apiKey.isBlank()) return + + flushLock.withLock { + val baseUrl = RtotaSettings.baseUrl + _state.value = _state.value.copy(uploading = true) + + // 1. The trip may not exist server-side yet (started out of coverage). + if (RtotaSettings.tripPendingCreate) { + val created = + RtotaClient.createTrip( + baseUrl = baseUrl, + apiKey = apiKey, + name = RtotaSettings.tripName, + startTimeMs = RtotaSettings.tripStartedMs, + notes = pendingNotes, + privacy = RtotaSettings.tripPrivacy.takeIf { it.isNotBlank() }, + ) + created.fold( + onSuccess = { handle -> + RtotaSettings.tripId = handle.id + RtotaSettings.tripShareToken = handle.shareToken.orEmpty() + RtotaSettings.tripPendingCreate = false + pendingNotes = null + _state.value = + _state.value.copy( + tripId = handle.id, + shareToken = handle.shareToken.orEmpty(), + pendingCreate = false, + ) + log("trip created id=${handle.id}") + }, + onFailure = { e -> + failed(e, "create") + return@withLock + }, + ) + } + + val tripId = RtotaSettings.tripId + if (tripId.isEmpty()) { + _state.value = _state.value.copy(uploading = false) + return@withLock + } + + // 2. Resume handshake, once per app start on an already-created trip. + // A process killed 300 miles in comes back holding a queue it can't + // tell apart from what already landed; one cheap GET turns "re-send + // the whole day" into "send the last few minutes". Best-effort by + // design: a failure here is not a delivery failure, so it must not + // trip the backoff — the plain re-send below still works, the server + // dedupes, and the only cost is bytes. + if (resumeHandshakePending) { + RtotaClient.fetchSyncState(baseUrl, apiKey, tripId).fold( + onSuccess = { sync -> + resumeHandshakePending = false + val pruned = q.pruneAcknowledgedQsos(sync.qsoDedupeKeys) + log( + "sync-state: server has ${sync.pointCount}pts/${sync.qsoCount}qsos " + + "status=${sync.status}${if (sync.truncated) " (keys truncated)" else ""}; " + + "pruned $pruned queued QSO(s)", + ) + publish() + }, + onFailure = { e -> + // Leave the flag set so the next pass tries again. + log("sync-state unavailable (continuing): ${e.message ?: e.javaClass.simpleName}") + }, + ) + } + + // 3. Ship the backlog, oldest first, one batch per request. + while (!q.isEmpty()) { + val batch = q.peekBatch() + if (batch.isEmpty) break + val result = RtotaClient.sendLive(baseUrl, apiKey, tripId, batch) + result.fold( + onSuccess = { ack -> + q.commit(batch) + failedAttempts = 0 + _state.value = + _state.value.copy( + sentPoints = _state.value.sentPoints + batch.points.size, + sentQsos = _state.value.sentQsos + ack.qsosInserted, + lastUploadMs = System.currentTimeMillis(), + lastError = null, + ) + publish() + }, + onFailure = { e -> + failed(e, "live") + return@withLock + }, + ) + } + + // 4. Finalize once nothing is left to send. + if (RtotaSettings.tripPendingComplete && q.isEmpty()) { + RtotaClient.completeTrip(baseUrl, apiKey, tripId).fold( + onSuccess = { + log("trip completed id=$tripId") + RtotaSettings.clearTrip() + sampler.reset() + lastRawFix = null + _state.value = RtotaTripState() + }, + onFailure = { e -> + failed(e, "complete") + return@withLock + }, + ) + } + + _state.value = _state.value.copy(uploading = false) + publish() + if (reason != "point") log("flush ok ($reason)") + } + } + + /** + * Record a failed step and, when it's the kind of failure that can heal + * itself, schedule a retry. A permanent failure (401 from a rotated key, 403 + * on someone else's trip) just surfaces in the UI: the queue keeps everything, + * so fixing the key and flushing again loses nothing. + */ + private fun failed( + error: Throwable, + step: String, + ) { + val retryable = isRetryableRtotaFailure(error) + val message = + when (error) { + is RtotaHttpException -> error.serverMessage ?: "HTTP ${error.httpCode}" + else -> error.message ?: error.javaClass.simpleName + } + _state.value = _state.value.copy(uploading = false, lastError = message) + publish() + log("$step failed (${if (retryable) "retrying" else "fatal"}): $message") + if (!retryable) { + failedAttempts = 0 + return + } + failedAttempts++ + scheduleRetry(rtotaBackoffMs(failedAttempts)) + } + + private fun scheduleRetry(delayMs: Long) { + retryJob?.cancel() + retryJob = + scope.launch { + delay(delayMs) + flush("retry") + } + } + + // ----------------------------------------------------------------------- + // Wiring + // ----------------------------------------------------------------------- + + @Suppress("TooGenericExceptionCaught") + private fun registerConnectivity(ctx: Context) { + if (connectivityRegistered) return + val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return + // VALIDATED, not merely INTERNET: an unvalidated network (captive portal, + // still associating) would just burn a flush pass. Same reasoning as + // QsoAutoSync. + val request = + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .build() + try { + cm.registerNetworkCallback(request, networkCallback) + connectivityRegistered = true + } catch (e: RuntimeException) { + // SecurityException on a locked-down device, or the platform's + // too-many-callbacks limit. Trip mode still works — flushes just fall + // back to the QSO/point triggers and the retry timer. + log("connectivity register failed: ${e.javaClass.simpleName}") + } + } + + private fun startTracking() { + val ctx = appContext ?: return + RtotaTripService.start(ctx) + } + + private fun stopTracking() { + val ctx = appContext ?: return + RtotaTripService.stop(ctx) + } + + /** Refresh the queue-derived counters and push them to the notification. */ + private fun publish() { + val q = queue + _state.value = + _state.value.copy( + pendingPoints = q?.pointCount() ?: 0, + pendingQsos = q?.qsoCount() ?: 0, + ) + appContext?.let { RtotaTripService.updateNotification(it, _state.value) } + } + + private fun log(msg: String) { + Log.d(TAG, msg) + try { + val ctx = appContext ?: GeneralVariables.getMainContext() ?: return + val dir = ctx.getExternalFilesDir(null) ?: return + val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(Date()) + FileWriter(File(dir, "debug.log"), true).use { it.append("$ts Rtota: $msg\n") } + } catch (_: Exception) { + } + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaTripService.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaTripService.kt new file mode 100644 index 000000000..49fe776a6 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaTripService.kt @@ -0,0 +1,213 @@ +package radio.ks3ckc.ft8af.rtota + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.k1af.ft8af.GeneralVariables +import com.k1af.ft8af.R +import java.util.Locale + +/** + * Keeps GPS running for the length of a trip. + * + * Android only allows sustained location access from the background to a + * foreground service that declares the `location` type, so trip tracking cannot + * live in the activity: the phone is usually in a cradle with the screen off (or + * showing a map app) while the rover drives. The service exists purely to hold + * that permission window open, own the [RtotaLocationTracker] subscription, and + * show the ongoing notification the OS requires — the queueing and uploading all + * stay in [RtotaTripManager], which outlives it. + * + * The notification doubles as the trip's dashboard: miles, contacts, and how much + * is still waiting to upload, plus a one-tap End Trip. + */ +class RtotaTripService : Service() { + private var tracker: RtotaLocationTracker? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + createChannel() + } + + // Broad catch below: a foreground-start refusal has several unrelated causes + // (missing permission, background-start policy, an OEM limit) and none of them + // is worth crashing a trip over. + @Suppress("TooGenericExceptionCaught") + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + if (intent?.action == ACTION_END_TRIP) { + GeneralVariables.fileLog("RtotaTripService: End Trip tapped") + RtotaTripManager.endTrip() + return START_NOT_STICKY + } + + val notification = buildNotification(this, RtotaTripManager.state.value) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + startForeground(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) + } else { + startForeground(NOTIF_ID, notification) + } + } catch (e: Exception) { + // Location permission missing, or a background-start restriction on + // Android 12+. Trip mode degrades to "QSOs only" rather than crashing. + GeneralVariables.fileLog("RtotaTripService start failed: $e") + stopSelf() + return START_NOT_STICKY + } + + val t = tracker ?: RtotaLocationTracker(this).also { tracker = it } + if (!t.start()) { + Log.d(TAG, "tracker did not start (no permission / no provider)") + } + running = true + // STICKY: if the OS reclaims the process mid-trip, restart tracking as + // soon as memory allows — the trip itself is restored from RtotaSettings. + return START_STICKY + } + + override fun onDestroy() { + running = false + tracker?.stop() + tracker = null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + super.onDestroy() + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return + if (nm.getNotificationChannel(CHANNEL_ID) != null) return + val channel = + NotificationChannel( + CHANNEL_ID, + getString(R.string.rtota_service_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = getString(R.string.rtota_service_channel_desc) + setShowBadge(false) + } + nm.createNotificationChannel(channel) + } + + companion object { + private const val TAG = "RtotaTripService" + private const val CHANNEL_ID = "rtota_trip" + private const val NOTIF_ID = 0x52544F54 // "RTOT" + const val ACTION_END_TRIP = "radio.ks3ckc.ft8af.rtota.END_TRIP" + + @Volatile + private var running = false + + @Suppress("TooGenericExceptionCaught") + fun start(context: Context) { + try { + ContextCompat.startForegroundService( + context, + Intent(context, RtotaTripService::class.java), + ) + } catch (e: Exception) { + GeneralVariables.fileLog("RtotaTripService.start failed: $e") + } + } + + fun stop(context: Context) { + try { + context.stopService(Intent(context, RtotaTripService::class.java)) + } catch (_: Exception) { + } + } + + /** Refresh the ongoing notification's counters. No-op when not running. */ + fun updateNotification( + context: Context, + state: RtotaTripState, + ) { + if (!running) return + try { + val nm = + context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + ?: return + nm.notify(NOTIF_ID, buildNotification(context, state)) + } catch (_: Exception) { + } + } + + /** + * Notification text, e.g. `142.3 mi · 37 QSOs · 12 waiting`. The "waiting" + * clause only appears when there is a backlog, so on a good connection the + * line stays quiet. + */ + internal fun notificationText(state: RtotaTripState): String { + val parts = + mutableListOf( + String.format(Locale.US, "%.1f mi", state.miles), + "${state.sentQsos + state.pendingQsos} QSOs", + ) + val waiting = state.pendingPoints + state.pendingQsos + if (waiting > 0) parts.add("$waiting waiting") + // Parked is worth saying out loud: the trail deliberately goes quiet, + // and without this the notification looks like tracking has broken. + if (state.parked) parts.add("parked") + if (state.pendingCreate) parts.add("offline start") + state.lastError?.let { parts.add(it.take(40)) } + return parts.joinToString(" · ") + } + + private fun buildNotification( + context: Context, + state: RtotaTripState, + ): Notification { + var piFlags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + piFlags = piFlags or PendingIntent.FLAG_IMMUTABLE + } + val openIntent = + Intent(context, radio.ks3ckc.ft8af.ComposeMainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + val openPi = PendingIntent.getActivity(context, 0, openIntent, piFlags) + val endPi = + PendingIntent.getService( + context, + 2, + Intent(context, RtotaTripService::class.java).setAction(ACTION_END_TRIP), + piFlags, + ) + val title = state.tripName.ifBlank { context.getString(R.string.rtota_service_title) } + return NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(notificationText(state)) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setContentIntent(openPi) + .addAction( + R.drawable.ic_baseline_close_32, + context.getString(R.string.rtota_action_end_trip), + endPi, + ) + .build() + } + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconSampler.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconSampler.kt new file mode 100644 index 000000000..8a78ad558 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconSampler.kt @@ -0,0 +1,341 @@ +package radio.ks3ckc.ft8af.rtota + +import kotlin.math.asin +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin +import kotlin.math.sqrt + +/** Great-circle distance in metres. Pure — the unit tests lean on it directly. */ +fun haversineMeters( + lat1: Double, + lon1: Double, + lat2: Double, + lon2: Double, +): Double { + val r = 6_371_000.0 + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = + sin(dLat / 2) * sin(dLat / 2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2) * sin(dLon / 2) + // min(1.0, …) guards asin's domain against floating-point overshoot for antipodes. + return 2 * r * asin(min(1.0, sqrt(a))) +} + +/** Smallest absolute angle between two compass bearings, 0–180 degrees. */ +fun headingDelta( + a: Double, + b: Double, +): Double { + // Normalise into [0,360) first so a wrap (350° -> 10°) reads as 20°, not 340°. + val d = ((a - b) % 360.0 + 360.0) % 360.0 + return if (d > 180.0) 360.0 - d else d +} + +/** Android's Location reports m/s; SmartBeaconing and the UI both work in mph. */ +internal const val MPS_TO_MPH = 2.2369363 + +internal const val METERS_PER_MILE = 1609.344 + +/** + * SmartBeaconing™ parameters (HamHUD, Tony Arnerich KD7TA / Steve Bragg KA9MVA), + * the same scheme APRSdroid, the Kenwood D710 and most APRS trackers use. + * + * Two rules, both keyed on speed: + * + * - **Rate**: below [slowSpeedMph] beacon every [slowRateSec]; above + * [fastSpeedMph] every [fastRateSec]; in between, `fastRate × fastSpeed / + * speed` — so the *distance* between points stays roughly constant instead of + * the time. A 70 mph interstate and a 25 mph town street produce a similarly + * dense trail. + * - **Corner pegging**: send immediately when the course changes by more than + * `minTurnAngleDeg + turnSlope / speedMph`, provided [minTurnTimeSec] has + * passed. The division is what makes it work at both ends: at 5 mph the + * threshold is enormous (only a hairpin counts, and a parked phone's drifting + * compass never triggers), while at 65 mph it drops to a few degrees, which is + * what a gentle interstate curve actually looks like. + * + * Two deviations from APRS, both because our "beacon" is a row in a batched HTTP + * queue rather than an RF transmission: + * + * - Rates are far tighter than APRS defaults (which exist to protect a shared + * 2 m channel). We are drawing a map line, so the trail can be dense. + * - A true standstill emits *nothing* — see [stationaryRadiusM]. APRS keeps + * beaconing so you stay visible; RTOTA derives overnight stops from ≥4 h gaps + * in the trail, so silence while parked is the signal, not a failure. + */ +data class SmartBeaconProfile( + val fastSpeedMph: Double, + val fastRateSec: Int, + val slowSpeedMph: Double, + val slowRateSec: Int, + val minTurnTimeSec: Int, + val minTurnAngleDeg: Double, + /** Degrees × mph. Divided by speed, added to [minTurnAngleDeg]. */ + val turnSlope: Double, + /** + * A corner only counts if the rover actually moved this far since the last + * point. Pure SmartBeaconing has no such guard because an APRS tracker reads + * course from GPS velocity, which goes undefined when stopped; Android keeps + * reporting the last bearing (and some devices feed in a magnetometer), so a + * phone idling at a red light swings its heading freely. Without this, every + * fuel stop grows a little scribble of points where the truck never moved. + */ + val turnMinDistanceM: Double, + /** Movement under this radius since the last point counts as parked. */ + val stationaryRadiusM: Double, + /** Fixes wobblier than this are ignored outright. */ + val maxAccuracyM: Double, +) { + companion object { + /** + * The tuning RTOTA ships. It is a road-trip service — the rover is in a + * vehicle — so there is one profile and no picker: numbers tuned for + * highway speeds, giving ~0.6 mi between points at 70 mph. + * + * Tests build variants with [copy] to isolate a single rule. + */ + val DEFAULT = + SmartBeaconProfile( + fastSpeedMph = 70.0, + fastRateSec = 30, + slowSpeedMph = 4.0, + slowRateSec = 180, + minTurnTimeSec = 12, + minTurnAngleDeg = 15.0, + turnSlope = 255.0, + turnMinDistanceM = 40.0, + stationaryRadiusM = 60.0, + maxAccuracyM = 100.0, + ) + } +} + +/** Why a point was kept — surfaced in the UI and the debug log. */ +enum class BeaconReason { + /** First fix of the trip. */ + FIRST, + + /** The speed-scaled time interval elapsed. */ + RATE, + + /** Course changed more than the speed-scaled turn threshold. */ + CORNER, + + /** The rover started moving again after being parked. */ + RESUME, + + /** Anchored to a logged contact so the QSO sits exactly on the route line. */ + QSO, +} + +/** A kept breadcrumb and the rule that kept it. */ +data class BeaconDecision(val point: TripPoint, val reason: BeaconReason) + +/** + * Turns a 1 Hz stream of GPS fixes into the smallest set of points that still + * draws the route honestly, using SmartBeaconing™ (see [SmartBeaconProfile]). + * + * The naive alternatives both fail on a road trip: a fixed time interval either + * floods the queue in town or cuts corners on the highway, and a fixed distance + * interval turns every curve into a chord. Speed-scaled rate plus corner pegging + * is the scheme APRS trackers settled on after two decades of exactly this + * problem, so this follows it rather than inventing something. + * + * Stateful only in the last kept point (plus a parked flag); every rule is pure + * and unit-tested. + */ +class SmartBeaconSampler( + /** Injected only so tests can isolate a rule; the app always uses the default. */ + private val profile: SmartBeaconProfile = SmartBeaconProfile.DEFAULT, +) { + + private var last: TripPoint? = null + + /** True once the rover has sat inside [SmartBeaconProfile.stationaryRadiusM]. */ + private var parked = false + + /** The last fix that was kept, for stamping QSOs and measuring the next one. */ + val lastAccepted: TripPoint? get() = last + + /** Whether the sampler currently considers the rover stopped. */ + val isParked: Boolean get() = parked + + /** Metres along the kept breadcrumbs — drives the live mileage readout. */ + var traveledMeters: Double = 0.0 + private set + + fun reset() { + last = null + parked = false + traveledMeters = 0.0 + } + + /** + * Offer a fix. Returns the decision when the point is worth keeping, else + * null. Accepting makes it the reference for the next call. + */ + fun offer(point: TripPoint): BeaconDecision? { + val decision = evaluate(last, point, parked, profile) ?: return null + accept(point, decision.reason == BeaconReason.RESUME) + return decision + } + + /** + * Force-keep a point because a QSO was just logged there, so the route has a + * vertex exactly where the contact happened and the QSO plots *on* the line + * rather than beside it. Returns null when the last kept point is already + * close enough in time and space to serve as that vertex. + */ + fun anchorForQso(point: TripPoint): BeaconDecision? { + val prev = last + if (prev != null) { + val elapsedSec = (point.timestampMs - prev.timestampMs) / 1000.0 + if (elapsedSec < 0) return null + val moved = haversineMeters(prev.latitude, prev.longitude, point.latitude, point.longitude) + if (elapsedSec <= QSO_ANCHOR_MERGE_SEC && moved <= QSO_ANCHOR_MERGE_M) return null + } + accept(point, resumed = true) + return BeaconDecision(point, BeaconReason.QSO) + } + + private fun accept( + point: TripPoint, + resumed: Boolean, + ) { + last?.let { + traveledMeters += haversineMeters(it.latitude, it.longitude, point.latitude, point.longitude) + } + last = point + if (resumed) parked = false + } + + companion object { + /** A QSO within this window of the last point needs no anchor of its own. */ + const val QSO_ANCHOR_MERGE_SEC = 20.0 + const val QSO_ANCHOR_MERGE_M = 25.0 + + /** + * Speed for a fix, in mph. Prefers the reported Doppler speed but falls + * back to distance ÷ time, taking the larger of the two — a fix that + * carries no speed (common indoors-to-outdoors, and on some chipsets + * right after a cold start) would otherwise read as parked and suppress + * the whole trail. Borrowed from APRSdroid's SmartBeaconing, which does + * the same max() for the same reason. + */ + internal fun effectiveSpeedMph( + prev: TripPoint, + next: TripPoint, + elapsedSec: Double, + ): Double { + val reported = max(prev.speedMph ?: 0.0, next.speedMph ?: 0.0) + if (elapsedSec <= 0.0) return reported + val moved = haversineMeters(prev.latitude, prev.longitude, next.latitude, next.longitude) + val derived = (moved / elapsedSec) * MPS_TO_MPH + return max(reported, derived) + } + + /** + * SmartBeaconing rate curve: seconds between points at a given speed. + * Constant at both ends, `fastRate × fastSpeed / speed` in between, which + * keeps the *spacing* of the points roughly constant. + */ + internal fun beaconRateSec( + speedMph: Double, + profile: SmartBeaconProfile, + ): Int = + when { + speedMph <= profile.slowSpeedMph -> profile.slowRateSec + speedMph >= profile.fastSpeedMph -> profile.fastRateSec + else -> + (profile.fastRateSec * profile.fastSpeedMph / speedMph) + .toInt() + .coerceIn(profile.fastRateSec, profile.slowRateSec) + } + + /** + * Corner-peg threshold in degrees: `minTurnAngle + turnSlope / speed`. + * Returns 180 (unreachable) at a standstill so a drifting compass can + * never peg a corner while parked. + */ + internal fun turnThresholdDeg( + speedMph: Double, + profile: SmartBeaconProfile, + ): Double { + if (speedMph <= 0.0) return 180.0 + return min(180.0, profile.minTurnAngleDeg + profile.turnSlope / speedMph) + } + + /** + * The whole decision, as a pure function of the previous kept point, the + * candidate, and whether the rover was parked. + */ + internal fun evaluate( + prev: TripPoint?, + next: TripPoint, + parked: Boolean, + profile: SmartBeaconProfile, + ): BeaconDecision? { + val accuracy = next.accuracyM + if (accuracy != null && accuracy > profile.maxAccuracyM) return null + if (prev == null) return BeaconDecision(next, BeaconReason.FIRST) + + val elapsedSec = (next.timestampMs - prev.timestampMs) / 1000.0 + // Out-of-order or duplicate fix — GPS can replay a cached one after a + // provider restart, and keeping it would draw the route backwards. + if (elapsedSec <= 0.0) return null + + val moved = haversineMeters(prev.latitude, prev.longitude, next.latitude, next.longitude) + val speedMph = effectiveSpeedMph(prev, next, elapsedSec) + + // Leaving a stop is worth a point of its own: it timestamps the + // departure and starts the next leg from the right place. + if (parked) { + return if (moved > profile.stationaryRadiusM) { + BeaconDecision(next, BeaconReason.RESUME) + } else { + null + } + } + + // Corner pegging comes first — a turn matters more than the clock. + val prevHeading = prev.headingDeg + val nextHeading = next.headingDeg + if (prevHeading != null && + nextHeading != null && + elapsedSec >= profile.minTurnTimeSec && + moved >= profile.turnMinDistanceM + ) { + val delta = headingDelta(prevHeading, nextHeading) + if (delta >= turnThresholdDeg(speedMph, profile)) { + return BeaconDecision(next, BeaconReason.CORNER) + } + } + + if (elapsedSec < beaconRateSec(speedMph, profile)) return null + + // The interval is up, but the rover hasn't gone anywhere: this is a + // stop, not a slow crawl. Stay silent so the gap survives into the + // server's overnight-stop derivation. + if (moved <= profile.stationaryRadiusM) return null + + return BeaconDecision(next, BeaconReason.RATE) + } + } + + /** + * Fold the "did we just become parked" bookkeeping into the offer path. + * Called by [RtotaTripManager] on every fix, including the dropped ones, so + * the parked flag reflects the *raw* stream rather than only kept points. + */ + fun noteStationary(point: TripPoint) { + val prev = last ?: return + if (parked) return + val elapsedSec = (point.timestampMs - prev.timestampMs) / 1000.0 + if (elapsedSec < profile.slowRateSec) return + val moved = haversineMeters(prev.latitude, prev.longitude, point.latitude, point.longitude) + if (moved <= profile.stationaryRadiusM) parked = true + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rtota/RoadTripScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rtota/RoadTripScreen.kt new file mode 100644 index 000000000..1754b7a95 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rtota/RoadTripScreen.kt @@ -0,0 +1,657 @@ +package radio.ks3ckc.ft8af.ui.rtota + +import android.Manifest +import android.app.Activity +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.app.ActivityCompat +import com.k1af.ft8af.GeneralVariables +import com.k1af.ft8af.R +import kotlinx.coroutines.launch +import radio.ks3ckc.ft8af.location.hasLocationPermission +import radio.ks3ckc.ft8af.rtota.BeaconReason +import radio.ks3ckc.ft8af.rtota.RTOTA_CQ_MODIFIER +import radio.ks3ckc.ft8af.rtota.RtotaClient +import radio.ks3ckc.ft8af.rtota.RtotaSettings +import radio.ks3ckc.ft8af.rtota.RtotaTripManager +import radio.ks3ckc.ft8af.rtota.RtotaTripState +import radio.ks3ckc.ft8af.rtota.SmartBeaconProfile +import radio.ks3ckc.ft8af.theme.* +import radio.ks3ckc.ft8af.ui.components.GlassCard +import radio.ks3ckc.ft8af.ui.components.SettingsRow +import radio.ks3ckc.ft8af.ui.settings.SettingsDetailScaffold +import radio.ks3ckc.ft8af.ui.settings.SettingsSection +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * RTOTA trip mode screen: register with rtota.app, start and end a road trip, + * watch what has reached the server, and announce a trip before it starts. + * + * The screen is a thin view over [RtotaTripManager] — it never touches GPS or + * the queue itself, so closing it (or the whole app) has no effect on a trip in + * progress. Everything long-running is reported through + * [RtotaTripManager.state] rather than owned here. + */ +@Composable +fun RoadTripScreen(onBack: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val state by RtotaTripManager.state.collectAsState() + + var enabled by remember { mutableStateOf(RtotaSettings.enabled) } + var callsign by remember { mutableStateOf(RtotaSettings.callsign) } + var baseUrl by remember { mutableStateOf(RtotaSettings.baseUrl) } + var apiKey by remember { mutableStateOf(RtotaSettings.apiKey) } + var privacy by remember { mutableStateOf(RtotaSettings.defaultPrivacy) } + var tripName by remember { mutableStateOf("") } + + var busy by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + + var showCallsignDialog by remember { mutableStateOf(false) } + var showServerDialog by remember { mutableStateOf(false) } + var showKeyDialog by remember { mutableStateOf(false) } + var showTripNameDialog by remember { mutableStateOf(false) } + var showAnnounceDialog by remember { mutableStateOf(false) } + + // Trip tracking needs location; ask here rather than at the moment the user + // taps Start, so a denied prompt doesn't silently produce a trip with no route. + fun ensureLocationPermission(): Boolean { + // Fine *or* coarse, matching what RtotaLocationTracker actually needs. + // Gating on fine alone rejected an approximate-only grant — an ordinary + // choice in the Android 12+ permission dialog — and re-prompted for a + // permission the user had already given. + val granted = hasLocationPermission(context) + if (!granted) { + (context as? Activity)?.let { + ActivityCompat.requestPermissions( + it, + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ), + REQUEST_LOCATION, + ) + } + } + return granted + } + + if (showCallsignDialog) { + RtotaTextDialog( + title = stringResource(R.string.rtota_callsign), + initial = callsign, + onDismiss = { showCallsignDialog = false }, + onSave = { + callsign = it.trim().uppercase(Locale.US) + RtotaSettings.callsign = callsign + showCallsignDialog = false + }, + ) + } + + if (showServerDialog) { + RtotaTextDialog( + title = stringResource(R.string.rtota_server), + initial = baseUrl, + onDismiss = { showServerDialog = false }, + onSave = { + RtotaSettings.baseUrl = it + baseUrl = RtotaSettings.baseUrl + showServerDialog = false + }, + ) + } + + if (showKeyDialog) { + RtotaTextDialog( + title = stringResource(R.string.rtota_api_key), + initial = apiKey, + hint = stringResource(R.string.rtota_api_key_hint), + onDismiss = { showKeyDialog = false }, + onSave = { + apiKey = it.trim() + RtotaSettings.apiKey = apiKey + showKeyDialog = false + // A key that arrives after a trip started offline unblocks the + // whole backlog. + RtotaTripManager.requestFlush("api-key-set") + }, + ) + } + + if (showTripNameDialog) { + RtotaTextDialog( + title = stringResource(R.string.rtota_trip_name), + initial = tripName, + hint = stringResource(R.string.rtota_trip_name_hint), + onDismiss = { showTripNameDialog = false }, + onSave = { + tripName = it.trim() + showTripNameDialog = false + }, + ) + } + + if (showAnnounceDialog) { + AnnounceActivationDialog( + onDismiss = { showAnnounceDialog = false }, + onAnnounce = { title, hoursFromNow -> + showAnnounceDialog = false + busy = true + scope.launch { + val start = System.currentTimeMillis() + hoursFromNow * 3_600_000L + val result = + RtotaClient.createActivation( + baseUrl = RtotaSettings.baseUrl, + apiKey = RtotaSettings.apiKey, + title = title, + startTimeMs = start, + privacy = privacy.takeIf { it.isNotBlank() }, + ) + busy = false + message = + result.fold( + onSuccess = { context.getString(R.string.rtota_announced) }, + onFailure = { it.message ?: it.javaClass.simpleName }, + ) + } + }, + ) + } + + SettingsDetailScaffold(title = stringResource(R.string.rtota_title), onBack = onBack) { + // ===================================================================== + // ACCOUNT + // ===================================================================== + SettingsSection(title = stringResource(R.string.rtota_section_account)) { + GlassCard(modifier = Modifier.fillMaxWidth()) { + Column { + SettingsRow( + label = stringResource(R.string.rtota_enable), + description = stringResource(R.string.rtota_enable_desc), + toggle = enabled, + onToggleChange = { checked -> + enabled = checked + RtotaSettings.enabled = checked + if (checked) RtotaTripManager.init(context) + }, + ) + SettingsRow( + label = stringResource(R.string.rtota_callsign), + value = + callsign.ifBlank { GeneralVariables.myCallsign.orEmpty() } + .ifBlank { "--" }, + showChevron = true, + onClick = { showCallsignDialog = true }, + ) + SettingsRow( + label = stringResource(R.string.rtota_server), + value = baseUrl.removePrefix("https://"), + showChevron = true, + onClick = { showServerDialog = true }, + ) + SettingsRow( + label = stringResource(R.string.rtota_api_key), + value = + if (apiKey.isBlank()) { + stringResource(R.string.common_not_configured) + } else { + maskKey(apiKey) + }, + showChevron = true, + onClick = { showKeyDialog = true }, + ) + if (apiKey.isBlank()) { + SettingsRow( + label = + if (busy) { + stringResource(R.string.rtota_registering) + } else { + stringResource(R.string.rtota_register) + }, + description = stringResource(R.string.rtota_register_desc), + showChevron = true, + onClick = { + if (busy) return@SettingsRow + busy = true + message = null + scope.launch { + val result = + RtotaClient.registerOperator( + baseUrl = RtotaSettings.baseUrl, + callsign = + callsign.ifBlank { + GeneralVariables.myCallsign.orEmpty() + }, + homeGrid = GeneralVariables.getMyMaidenheadGrid(), + ) + busy = false + result.fold( + onSuccess = { key -> + apiKey = key + RtotaSettings.apiKey = key + message = context.getString(R.string.rtota_registered) + }, + onFailure = { + message = it.message ?: it.javaClass.simpleName + }, + ) + } + }, + ) + } + } + } + message?.let { NoticeText(it) } + } + + // ===================================================================== + // TRIP + // ===================================================================== + SettingsSection(title = stringResource(R.string.rtota_section_trip)) { + GlassCard(modifier = Modifier.fillMaxWidth()) { + if (state.active) { + ActiveTripCard( + state = state, + onEnd = { RtotaTripManager.endTrip() }, + onAbandon = { RtotaTripManager.abandonTrip() }, + ) + } else { + Column { + SettingsRow( + label = stringResource(R.string.rtota_trip_name), + value = tripName.ifBlank { "--" }, + showChevron = true, + onClick = { showTripNameDialog = true }, + ) + SettingsRow( + label = stringResource(R.string.rtota_privacy), + value = + privacy.ifBlank { + stringResource(R.string.rtota_privacy_default) + }, + onClick = { + // Cycle: account default -> public -> delayed -> + // followers -> private -> back to default. + privacy = nextPrivacy(privacy) + RtotaSettings.defaultPrivacy = privacy + }, + ) + SettingsRow( + label = stringResource(R.string.rtota_start_trip), + showChevron = true, + onClick = { + when { + RtotaSettings.apiKey.isBlank() -> + message = context.getString(R.string.rtota_need_key) + !ensureLocationPermission() -> + message = context.getString(R.string.rtota_need_location) + else -> { + if (!RtotaSettings.enabled) { + RtotaSettings.enabled = true + enabled = true + } + RtotaTripManager.init(context) + RtotaTripManager.startTrip( + name = tripName, + privacy = privacy.takeIf { it.isNotBlank() }, + ) + message = null + } + } + }, + ) + } + } + } + } + + // ===================================================================== + // TRACKING (SmartBeaconing) + // ===================================================================== + SettingsSection(title = stringResource(R.string.rtota_section_tracking)) { + GlassCard(modifier = Modifier.fillMaxWidth()) { + // Read-only: the sampling is tuned for a vehicle and there is + // nothing here worth a knob. Stating what it does still matters — + // a trail that goes quiet at a fuel stop looks broken otherwise. + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.rtota_beacon_heading), + color = TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = describeProfile(SmartBeaconProfile.DEFAULT), + color = TextMuted, + fontSize = 12.sp, + modifier = Modifier.padding(top = 6.dp), + ) + } + } + } + + // ===================================================================== + // ANNOUNCE + // ===================================================================== + SettingsSection(title = stringResource(R.string.rtota_section_activation)) { + GlassCard(modifier = Modifier.fillMaxWidth()) { + SettingsRow( + label = stringResource(R.string.rtota_announce), + description = stringResource(R.string.rtota_announce_desc), + showChevron = true, + onClick = { + if (RtotaSettings.apiKey.isBlank()) { + message = context.getString(R.string.rtota_need_key) + } else { + showAnnounceDialog = true + } + }, + ) + } + } + } +} + +private const val REQUEST_LOCATION = 4201 + +/** Live trip readout: what has been recorded, what is still waiting. */ +@Composable +private fun ActiveTripCard( + state: RtotaTripState, + onEnd: () -> Unit, + onAbandon: () -> Unit, +) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = state.tripName.ifBlank { stringResource(R.string.rtota_service_title) }, + color = TextPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + StatLine( + stringResource(R.string.rtota_stat_miles), + String.format(Locale.US, "%.1f", state.miles), + ) + StatLine( + stringResource(R.string.rtota_stat_qsos), + "${state.sentQsos}", + ) + StatLine( + stringResource(R.string.rtota_stat_pending), + "${state.pendingPoints + state.pendingQsos}", + ) + StatLine( + stringResource(R.string.rtota_stat_last_upload), + if (state.lastUploadMs > 0) { + SimpleDateFormat("HH:mm:ss", Locale.US).format(Date(state.lastUploadMs)) + } else { + stringResource(R.string.rtota_stat_never) + }, + ) + // Why the last route point was kept — makes SmartBeaconing legible from + // the passenger seat ("corner" on a curve, "parked" at a fuel stop). + StatLine( + stringResource(R.string.rtota_stat_last_point), + when { + state.parked -> stringResource(R.string.rtota_beacon_parked) + else -> stringResource(beaconReasonLabelRes(state.lastBeaconReason)) + }, + ) + // Spelled out because it is not the spelling anyone expects: an FT8 CQ + // modifier is at most four letters, so the trip calls RTOA, not RTOTA. + StatLine( + stringResource(R.string.rtota_stat_cq), + "CQ $RTOTA_CQ_MODIFIER", + ) + if (state.pendingCreate) { + Text( + text = stringResource(R.string.rtota_pending_create), + color = TextMuted, + fontSize = 12.sp, + ) + } + state.lastError?.let { + Text( + text = stringResource(R.string.rtota_error, it), + color = StatusBad, + fontSize = 12.sp, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TextButton(onClick = onEnd) { + Text(stringResource(R.string.rtota_end_trip), color = Accent) + } + TextButton(onClick = onAbandon) { + Text(stringResource(R.string.rtota_abandon), color = TextMuted) + } + } + } +} + +@Composable +private fun StatLine( + label: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = label, color = TextMuted, fontSize = 13.sp) + Text( + text = value, + color = TextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + ) + } +} + +@Composable +private fun NoticeText(text: String) { + Text( + text = text, + color = TextMuted, + fontSize = 12.sp, + modifier = Modifier.padding(start = 4.dp, top = 4.dp), + ) +} + +/** Single-field dialog shared by the callsign / server / key / trip-name rows. */ +@Composable +private fun RtotaTextDialog( + title: String, + initial: String, + hint: String? = null, + onDismiss: () -> Unit, + onSave: (String) -> Unit, +) { + var input by remember { mutableStateOf(initial) } + androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(BgSurface2) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(text = title, color = TextPrimary, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + OutlinedTextField( + value = input, + onValueChange = { input = it }, + placeholder = hint?.let { { Text(it, color = TextFaint) } }, + singleLine = true, + colors = rtotaFieldColors(), + textStyle = TextStyle(fontSize = 14.sp), + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel), color = TextMuted) + } + TextButton(onClick = { onSave(input) }) { + Text(stringResource(R.string.action_save), color = Accent) + } + } + } + } +} + +/** Title + "starting in N hours" — enough to put a plan on the map. */ +@Composable +private fun AnnounceActivationDialog( + onDismiss: () -> Unit, + onAnnounce: (title: String, hoursFromNow: Long) -> Unit, +) { + var title by remember { mutableStateOf("") } + var hours by remember { mutableStateOf("24") } + androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(BgSurface2) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.rtota_announce), + color = TextPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text(stringResource(R.string.rtota_announce_title_field)) }, + singleLine = true, + colors = rtotaFieldColors(), + textStyle = TextStyle(fontSize = 14.sp), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = hours, + onValueChange = { hours = it.filter { c -> c.isDigit() }.take(4) }, + label = { Text(stringResource(R.string.rtota_announce_hours)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = rtotaFieldColors(), + textStyle = TextStyle(fontSize = 14.sp), + modifier = Modifier.fillMaxWidth(), + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel), color = TextMuted) + } + TextButton( + onClick = { + if (title.isNotBlank()) { + onAnnounce(title, hours.toLongOrNull()?.coerceIn(0, 8760) ?: 24L) + } + }, + ) { + Text(stringResource(R.string.rtota_announce_send), color = Accent) + } + } + } + } +} + +@Composable +private fun rtotaFieldColors() = + OutlinedTextFieldDefaults.colors( + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + cursorColor = Accent, + focusedBorderColor = Accent, + unfocusedBorderColor = BorderStrong, + focusedLabelColor = Accent, + unfocusedLabelColor = TextMuted, + ) + +// --------------------------------------------------------------------------- +// Small pure helpers (unit-tested) +// --------------------------------------------------------------------------- + +/** + * Show enough of the key to recognise it without printing a working credential + * on a screen that may be mirrored to a car display. + */ +internal fun maskKey(key: String): String = if (key.length <= 8) "••••" else key.take(6) + "…" + key.takeLast(4) + +/** Privacy cycle for the tap-to-change row; "" means "let the account decide". */ +internal fun nextPrivacy(current: String): String { + val order = listOf("") + RtotaSettings.PRIVACY_LEVELS + val idx = order.indexOf(current).takeIf { it >= 0 } ?: 0 + return order[(idx + 1) % order.size] +} + +@StringRes +internal fun beaconReasonLabelRes(reason: BeaconReason?): Int = + when (reason) { + BeaconReason.FIRST -> R.string.rtota_beacon_first + BeaconReason.RATE -> R.string.rtota_beacon_rate + BeaconReason.CORNER -> R.string.rtota_beacon_corner + BeaconReason.RESUME -> R.string.rtota_beacon_resume + BeaconReason.QSO -> R.string.rtota_beacon_qso + null -> R.string.rtota_beacon_waiting + } + +/** + * Plain-language summary of what the sampler will actually do, so the numbers + * behind SmartBeaconing are visible without an options screen full of them. + */ +internal fun describeProfile(profile: SmartBeaconProfile): String = + buildString { + append("A point every ") + append(profile.fastRateSec) + append(" s above ") + append(profile.fastSpeedMph.toInt()) + append(" mph, stretching to ") + append(profile.slowRateSec / 60) + append(" min when crawling; extra points through turns sharper than ") + append(profile.minTurnAngleDeg.toInt()) + append("° + ") + append(profile.turnSlope.toInt()) + append("/mph. Nothing at all while parked.") + } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt index d100f979f..0243f99ea 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt @@ -55,6 +55,7 @@ import com.k1af.ft8af.R import com.k1af.ft8af.ft8signal.FT8Package import com.k1af.ft8af.location.GridLocationUpdater import radio.ks3ckc.ft8af.theme.* +import radio.ks3ckc.ft8af.ui.rtota.RoadTripScreen import radio.ks3ckc.ft8af.ui.components.GlassCard import radio.ks3ckc.ft8af.ui.components.SettingsRow import radio.ks3ckc.ft8af.ui.components.TopBar @@ -69,6 +70,7 @@ private enum class SettingsCategory { TIME_SYNC, DECODE_FILTERS, LOGGING, + ROAD_TRIP, ADVANCED, USB_DIAGNOSTICS, ABOUT, @@ -142,6 +144,8 @@ fun SettingsScreen( DecodeFilterSettings(mainViewModel, onBack = { currentCategory = null }) SettingsCategory.LOGGING -> LoggingSettings(mainViewModel, onBack = { currentCategory = null }) + SettingsCategory.ROAD_TRIP -> + RoadTripScreen(onBack = { currentCategory = null }) SettingsCategory.ADVANCED -> AdvancedSettings(mainViewModel, onBack = { currentCategory = null }) SettingsCategory.USB_DIAGNOSTICS -> @@ -323,6 +327,13 @@ private fun SettingsLanding( onClick = { onOpenCategory(SettingsCategory.LOGGING) }, ) SectionDivider() + SettingsRow( + label = stringResource(R.string.rtota_title), + description = stringResource(R.string.rtota_settings_desc), + showChevron = true, + onClick = { onOpenCategory(SettingsCategory.ROAD_TRIP) }, + ) + SectionDivider() SettingsRow( label = stringResource(R.string.settings_cat_advanced), showChevron = true, diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 3189dcddd..c4190c7ac 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -878,4 +878,61 @@ Recent decodes No decodes yet POTA %1$s · %2$d QSOs + + + Road Trips (RTOTA) + Stream your route and contacts to rtota.app while you drive + ACCOUNT + Trip mode + Record GPS breadcrumbs and push logged QSOs to rtota.app + Rover callsign + Server + API key + Register this callsign + Creates the operator on the server and saves the API key here + Registering… + Registered — API key saved + Paste the key from your rtota.app dashboard + TRIP + Trip name + e.g. Route 66 westbound + Privacy + Account default + Start trip + End trip + Finishing up — uploading the rest… + Discard local trip + Stops tracking and drops anything not yet uploaded + Miles + Contacts sent + Waiting to upload + Last upload + never + Started offline — the trip is created as soon as you have signal + Last error: %1$s + Share link + TRACKING + SmartBeaconing + Last point + Calling + trip start + interval + corner + moving again + contact + waiting for GPS + parked — not recording + ANNOUNCE + Announce a planned trip + Tell followers when you are heading out + Title + Starting in (hours) + Announce + Announcement posted + Set your API key first + Location permission is required for trip tracking + Road trip tracking + Records your route while a trip is running + Road trip in progress + End trip diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifLocationTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifLocationTest.java new file mode 100644 index 000000000..1a84e29de --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifLocationTest.java @@ -0,0 +1,134 @@ +package com.k1af.ft8af.log; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Operator position in ADIF: the standard {@code MY_LAT}/{@code MY_LON} Location + * datatype ({@code XDDD MM.MMM}) and its exact decimal twin. + * + *

The sign lives entirely in the hemisphere letter, so an error there moves a QSO to + * the wrong side of the planet rather than merely mislocating it — hence the explicit + * south/west cases. The 60.000-minute carry is the other trap: rounding a coordinate + * whose minutes land just under 60 produces a value the format has no way to express. + * Pure JUnit. + */ +public class AdifLocationTest { + + @Test + public void location_formatsNorthAndWestWithFixedWidth() { + // Denver: 39.7392, -104.9903. + assertThat(AdifFormat.location(39.7392, true)).isEqualTo("N039 44.352"); + assertThat(AdifFormat.location(-104.9903, false)).isEqualTo("W104 59.418"); + } + + @Test + public void location_degreesAreAlwaysThreeDigits() { + // Importers parse this format by position, so the padding is not cosmetic. + assertThat(AdifFormat.location(9.5, true)).isEqualTo("N009 30.000"); + assertThat(AdifFormat.location(0.5, false)).isEqualTo("E000 30.000"); + } + + @Test + public void location_hemisphereCarriesTheSign() { + assertThat(AdifFormat.location(-33.8688, true)).startsWith("S033"); + assertThat(AdifFormat.location(151.2093, false)).startsWith("E151"); + } + + @Test + public void location_zeroIsNorthAndEast() { + assertThat(AdifFormat.location(0.0, true)).isEqualTo("N000 00.000"); + assertThat(AdifFormat.location(0.0, false)).isEqualTo("E000 00.000"); + } + + @Test + public void location_carriesRatherThanEmittingSixtyMinutes() { + // 39.999992 deg is 59.99952 minutes, which rounds to 60.000 — not a legal value, + // so the degree has to absorb it. + String formatted = AdifFormat.location(39.999992, true); + assertThat(formatted).isEqualTo("N040 00.000"); + assertThat(formatted).doesNotContain("60.000"); + + // Just below the carry threshold the minutes stay put — the branch must not + // fire early and shift a coordinate a whole minute north. + assertThat(AdifFormat.location(39.99999, true)).isEqualTo("N039 59.999"); + } + + @Test + public void location_rejectsAbsentAndOutOfRangeValues() { + assertThat(AdifFormat.location(null, true)).isNull(); + assertThat(AdifFormat.location(Double.NaN, true)).isNull(); + assertThat(AdifFormat.location(91.0, true)).isNull(); // latitude limit + assertThat(AdifFormat.location(181.0, false)).isNull(); // longitude limit + // 91 is a perfectly good longitude, just not a latitude. + assertThat(AdifFormat.location(91.0, false)).isNotNull(); + } + + @Test + public void parseLocation_roundTripsWhatWeWrite() { + double lat = 39.7392; + double lon = -104.9903; + Double parsedLat = AdifFormat.parseLocation(AdifFormat.location(lat, true)); + Double parsedLon = AdifFormat.parseLocation(AdifFormat.location(lon, false)); + assertThat(parsedLat).isNotNull(); + assertThat(parsedLon).isNotNull(); + // The format quantizes to a thousandth of a minute — about 1.8 m. + assertThat(Math.abs(parsedLat - lat)).isLessThan(0.0001); + assertThat(Math.abs(parsedLon - lon)).isLessThan(0.0001); + } + + @Test + public void parseLocation_readsSouthAndWestAsNegative() { + assertThat(AdifFormat.parseLocation("S033 52.128")).isLessThan(0.0); + assertThat(AdifFormat.parseLocation("W104 59.418")).isLessThan(0.0); + assertThat(AdifFormat.parseLocation("N039 44.352")).isGreaterThan(0.0); + } + + @Test + public void parseLocation_rejectsMalformedValuesRatherThanGuessing() { + // A mis-parse puts the QSO in the wrong hemisphere, which beats having none. + assertThat(AdifFormat.parseLocation(null)).isNull(); + assertThat(AdifFormat.parseLocation("")).isNull(); + assertThat(AdifFormat.parseLocation("39.7392")).isNull(); // no hemisphere + assertThat(AdifFormat.parseLocation("X039 44.352")).isNull(); // bad hemisphere + assertThat(AdifFormat.parseLocation("N039")).isNull(); // no minutes + assertThat(AdifFormat.parseLocation("N039 61.000")).isNull(); // minutes out of range + } + + @Test + public void decimalDegrees_keepsFullPrecisionForTheAppFields() { + assertThat(AdifFormat.decimalDegrees(39.7392)).isEqualTo("39.739200"); + assertThat(AdifFormat.decimalDegrees(-104.9903)).isEqualTo("-104.990300"); + assertThat(AdifFormat.decimalDegrees(null)).isNull(); + assertThat(AdifFormat.decimalDegrees(Double.NaN)).isNull(); + } + + @Test + public void record_emitsBothFieldPairsWhenPositionIsKnown() { + String adif = new AdifRecord() + .call("K1ABC") + .myLat(39.7392) + .myLon(-104.9903) + .build(); + assertThat(adif).contains("N039 44.352"); + assertThat(adif).contains("W104 59.418"); + assertThat(adif).contains("39.739200"); + assertThat(adif).contains("-104.990300"); + } + + @Test + public void record_emitsNoPositionFieldsWhenUnknown() { + String adif = new AdifRecord().call("K1ABC").build(); + assertThat(adif).doesNotContain("MY_LAT"); + assertThat(adif).doesNotContain("APP_RTOTA_LAT"); + } + + @Test + public void record_emitsNothingWhenOnlyHalfThePositionIsKnown() { + // A lone longitude would place the QSO on the equator. + String adif = new AdifRecord().call("K1ABC").myLat(39.7392).build(); + assertThat(adif).doesNotContain("MY_LAT"); + assertThat(adif).doesNotContain("MY_LON"); + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/location/LocationPermissionsTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/location/LocationPermissionsTest.kt new file mode 100644 index 000000000..18a6568fd --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/location/LocationPermissionsTest.kt @@ -0,0 +1,68 @@ +package radio.ks3ckc.ft8af.location + +import android.Manifest +import android.content.pm.PackageManager +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +/** + * The permission predicate trip mode gates on. + * + * The case that matters is the middle one: since Android 12 the system dialog + * lets the user answer "Approximate", granting COARSE while denying FINE. A + * check that insists on FINE reads that as a refusal — which is what stopped + * Start trip working for a permission the user had already granted, while the + * tracker underneath was perfectly willing to run on it. + * + * Robolectric so real permission grants can be simulated. + */ +@RunWith(RobolectricTestRunner::class) +class LocationPermissionsTest { + private val app = ApplicationProvider.getApplicationContext() + + private fun grant(vararg permissions: String) { + shadowOf(app).grantPermissions(*permissions) + } + + private fun denyAll() { + shadowOf(app).denyPermissions( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ) + } + + @Test + fun `precise location is enough`() { + denyAll() + grant(Manifest.permission.ACCESS_FINE_LOCATION) + assertThat(hasLocationPermission(app)).isTrue() + } + + @Test + fun `approximate location alone is enough`() { + // The regression: an "Approximate" answer to the Android 12+ dialog. + denyAll() + grant(Manifest.permission.ACCESS_COARSE_LOCATION) + assertThat(hasLocationPermission(app)).isTrue() + } + + @Test + fun `both granted is enough`() { + denyAll() + grant(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) + assertThat(hasLocationPermission(app)).isTrue() + } + + @Test + fun `neither granted is a refusal`() { + denyAll() + assertThat(hasLocationPermission(app)).isFalse() + assertThat( + app.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION), + ).isEqualTo(PackageManager.PERMISSION_DENIED) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/location/RoverPositionTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/location/RoverPositionTest.kt new file mode 100644 index 000000000..cdeda9168 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/location/RoverPositionTest.kt @@ -0,0 +1,85 @@ +package radio.ks3ckc.ft8af.location + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Which position gets stamped on a QSO. + * + * Two rules, and both are about not overstating what the app knows. Priority + * beats freshness, because a live trip fix is a better account of the drive than + * a last-known location of unknown provenance even when it is older. And when + * every candidate is stale the answer is *nothing* — an unlocated QSO is honest, + * and rtota.app places it from the breadcrumb trail on upload. + */ +class RoverPositionTest { + private fun fix(source: RoverPositionSource, ageMs: Long) = + RoverFix(39.7392, -104.9903, ageMs, source) + + @Test + fun `a live trip fix wins when it is fresh`() { + val chosen = + chooseRoverFix( + listOf( + fix(RoverPositionSource.LIVE_FIX, 5_000L), + fix(RoverPositionSource.LAST_KNOWN, 1_000L), + ), + ) + assertThat(chosen?.source).isEqualTo(RoverPositionSource.LIVE_FIX) + } + + @Test + fun `an older live fix still beats a newer last-known one`() { + val chosen = + chooseRoverFix( + listOf( + fix(RoverPositionSource.LIVE_FIX, MAX_ROVER_FIX_AGE_MS - 1), + fix(RoverPositionSource.LAST_KNOWN, 0L), + ), + ) + assertThat(chosen?.source).isEqualTo(RoverPositionSource.LIVE_FIX) + } + + @Test + fun `a stale fix is skipped for the next usable candidate`() { + // Yesterday's drive must not place today's contact. + val chosen = + chooseRoverFix( + listOf( + fix(RoverPositionSource.LIVE_FIX, MAX_ROVER_FIX_AGE_MS + 1), + fix(RoverPositionSource.LAST_KNOWN, 60_000L), + ), + ) + assertThat(chosen?.source).isEqualTo(RoverPositionSource.LAST_KNOWN) + } + + @Test + fun `every stale candidate means no position at all, never an approximation`() { + // No grid-centre tier exists on purpose: a 4-character grid is ~55 km across, + // and its centre written into MY_LAT/MY_LON would read as a measurement. + // rtota.app infers a position from the breadcrumb trail instead. + val chosen = + chooseRoverFix( + listOf( + fix(RoverPositionSource.LIVE_FIX, MAX_ROVER_FIX_AGE_MS + 1), + fix(RoverPositionSource.LAST_KNOWN, MAX_ROVER_FIX_AGE_MS + 1), + ), + ) + assertThat(chosen).isNull() + } + + @Test + fun `absent candidates are skipped rather than crashing`() { + val chosen = chooseRoverFix(listOf(null, fix(RoverPositionSource.LAST_KNOWN, 1_000L))) + assertThat(chosen?.source).isEqualTo(RoverPositionSource.LAST_KNOWN) + } + + @Test + fun `nothing available yields null, so the QSO is logged without a position`() { + assertThat(chooseRoverFix(emptyList())).isNull() + assertThat(chooseRoverFix(listOf(null, null))).isNull() + assertThat( + chooseRoverFix(listOf(fix(RoverPositionSource.LAST_KNOWN, MAX_ROVER_FIX_AGE_MS + 1))), + ).isNull() + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/HighwayCachePolicyTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/HighwayCachePolicyTest.kt new file mode 100644 index 000000000..b9a9a349c --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/HighwayCachePolicyTest.kt @@ -0,0 +1,101 @@ +package radio.ks3ckc.ft8af.rtota + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * When to re-geocode, and how long an answer keeps applying. + * + * Both gates exist because either one alone fails a real case: parked at a fuel + * stop the rover never moves, and at 80 mph it covers a mile before a + * time-only gate would notice a junction. The expiry rules matter more still — + * a label that never expired would carry "I-70" through a two-hour dead zone and + * invent a route the rover may have left at the first exit. + */ +class HighwayCachePolicyTest { + private val denver = 39.7392 to -104.9903 + private val now = 1_700_000_000_000L + + private fun metersEast(from: Pair, meters: Double): Pair { + // ~111.32 km per degree of longitude at the equator, shrunk by cos(latitude). + val degPerMeter = 1.0 / (111_320.0 * Math.cos(Math.toRadians(from.first))) + return from.first to (from.second + meters * degPerMeter) + } + + @Test + fun `the first fix always refreshes`() { + assertThat( + shouldRefreshHighway(0L, 0.0, 0.0, now, denver.first, denver.second), + ).isTrue() + } + + @Test + fun `a fix seconds later in the same spot does not refresh`() { + assertThat( + shouldRefreshHighway(now, denver.first, denver.second, now + 2_000L, denver.first, denver.second), + ).isFalse() + } + + @Test + fun `enough elapsed time refreshes even while parked`() { + assertThat( + shouldRefreshHighway( + now, denver.first, denver.second, + now + HIGHWAY_REFRESH_MIN_INTERVAL_MS, denver.first, denver.second, + ), + ).isTrue() + } + + @Test + fun `enough distance refreshes even within the interval`() { + val far = metersEast(denver, HIGHWAY_REFRESH_MIN_METERS + 100.0) + assertThat( + shouldRefreshHighway(now, denver.first, denver.second, now + 1_000L, far.first, far.second), + ).isTrue() + } + + @Test + fun `a fresh nearby label is reused`() { + val label = + highwayLabelForPoint( + "I-70", now, denver.first, denver.second, + now + 5_000L, denver.first, denver.second, + ) + assertThat(label).isEqualTo("I-70") + } + + @Test + fun `a label expires with time`() { + val label = + highwayLabelForPoint( + "I-70", now, denver.first, denver.second, + now + HIGHWAY_STALE_AFTER_MS + 1, denver.first, denver.second, + ) + assertThat(label).isNull() + } + + @Test + fun `a label expires with distance`() { + val far = metersEast(denver, HIGHWAY_STALE_AFTER_METERS + 500.0) + val label = + highwayLabelForPoint( + "I-70", now, denver.first, denver.second, + now + 1_000L, far.first, far.second, + ) + assertThat(label).isNull() + } + + @Test + fun `no cached label yields null rather than an empty string`() { + assertThat( + highwayLabelForPoint(null, now, denver.first, denver.second, now, denver.first, denver.second), + ).isNull() + assertThat( + highwayLabelForPoint("", now, denver.first, denver.second, now, denver.first, denver.second), + ).isNull() + // A label with no timestamp was never actually resolved. + assertThat( + highwayLabelForPoint("I-70", 0L, denver.first, denver.second, now, denver.first, denver.second), + ).isNull() + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/HighwayClassifierTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/HighwayClassifierTest.kt new file mode 100644 index 000000000..f7a811fb7 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/HighwayClassifierTest.kt @@ -0,0 +1,98 @@ +package radio.ks3ckc.ft8af.rtota + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Road-name canonicalization. + * + * The service groups these into a per-trip "highways traveled" list, so the real + * requirement is that every spelling of one road collapses to one label — the + * geocoder is free to say "I-70", "I 70 E" or "Interstate 70" for the same + * asphalt, and a roll-up listing all three is noise. + */ +class HighwayClassifierTest { + @Test + fun `interstates collapse to one label however they are spelled`() { + val expected = "I-70" + assertThat(classifyHighway("I-70")).isEqualTo(expected) + assertThat(classifyHighway("I 70")).isEqualTo(expected) + assertThat(classifyHighway("Interstate 70")).isEqualTo(expected) + assertThat(classifyHighway("I-70 E")).isEqualTo(expected) + assertThat(classifyHighway("E I-70")).isEqualTo(expected) + assertThat(classifyHighway("i-70")).isEqualTo(expected) + } + + @Test + fun `US routes collapse to one label`() { + val expected = "US-285" + assertThat(classifyHighway("US-285")).isEqualTo(expected) + assertThat(classifyHighway("US 285")).isEqualTo(expected) + assertThat(classifyHighway("U.S. 285")).isEqualTo(expected) + assertThat(classifyHighway("US Highway 285")).isEqualTo(expected) + assertThat(classifyHighway("US-285 S")).isEqualTo(expected) + assertThat(classifyHighway("US Route 285")).isEqualTo(expected) + } + + @Test + fun `a state-prefixed route keeps its own state, not the rover's`() { + // Near a border the geocoder is the better authority: it named the road. + assertThat(classifyHighway("CO-93", stateCode = "CO")).isEqualTo("CO-93") + assertThat(classifyHighway("WY-230", stateCode = "CO")).isEqualTo("WY-230") + assertThat(classifyHighway("TX 130")).isEqualTo("TX-130") + } + + @Test + fun `an unprefixed state route borrows the state the rover is in`() { + assertThat(classifyHighway("State Highway 7", stateCode = "CO")).isEqualTo("CO-7") + assertThat(classifyHighway("SH 7", stateCode = "CO")).isEqualTo("CO-7") + assertThat(classifyHighway("State Route 520", stateCode = "WA")).isEqualTo("WA-520") + } + + @Test + fun `an unprefixed state route with no known state stays generic-prefixed`() { + // Better an honest "SR-7" than a guessed state the QSO wasn't in. + assertThat(classifyHighway("State Highway 7", stateCode = null)).isEqualTo("SR-7") + assertThat(classifyHighway("SR 520", stateCode = "ZZ")).isEqualTo("SR-520") + } + + @Test + fun `two-letter prefixes that are not states do not become state routes`() { + // "CR 73" is a county road and "FM 1960" a Texas farm-to-market road; neither + // is a state route, and neither may invent the state code "CR"/"FM". + assertThat(classifyHighway("CR 73")).isEqualTo(LOCAL_ROAD_LABEL) + assertThat(classifyHighway("County Road 73")).isEqualTo(LOCAL_ROAD_LABEL) + assertThat(classifyHighway("FM 1960")).isEqualTo(LOCAL_ROAD_LABEL) + } + + @Test + fun `a state route is not mistaken for an interstate`() { + // "IA-80" and "IN-65" begin with I but are Iowa and Indiana state routes; the + // interstate pattern must not swallow them. + assertThat(classifyHighway("IA-80")).isEqualTo("IA-80") + assertThat(classifyHighway("IN-65")).isEqualTo("IN-65") + assertThat(classifyHighway("HI-1")).isEqualTo("HI-1") + } + + @Test + fun `ordinary streets fall into the generic bucket`() { + assertThat(classifyHighway("W Colfax Ave")).isEqualTo(LOCAL_ROAD_LABEL) + assertThat(classifyHighway("Main Street")).isEqualTo(LOCAL_ROAD_LABEL) + assertThat(classifyHighway("Pearl St")).isEqualTo(LOCAL_ROAD_LABEL) + } + + @Test + fun `an unresolved road is null, which is not the same as city driving`() { + // The offline case. Claiming city driving across a dead zone would invent a + // fact; null says only that nothing was resolved. + assertThat(classifyHighway(null)).isNull() + assertThat(classifyHighway("")).isNull() + assertThat(classifyHighway(" ")).isNull() + } + + @Test + fun `the generic label is a fixed English constant, not a localized string`() { + // It is grouped server-side; a localized spelling would split the bucket. + assertThat(LOCAL_ROAD_LABEL).isEqualTo("Local roads") + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RoadTripScreenLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RoadTripScreenLogicTest.kt new file mode 100644 index 000000000..304c5fbb3 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RoadTripScreenLogicTest.kt @@ -0,0 +1,84 @@ +package radio.ks3ckc.ft8af.rtota + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import radio.ks3ckc.ft8af.ui.rtota.describeProfile +import radio.ks3ckc.ft8af.ui.rtota.maskKey +import radio.ks3ckc.ft8af.ui.rtota.nextPrivacy + +/** + * The decision logic pulled out of the Compose screen (tap-to-cycle rows, key + * masking) plus the trip notification's summary line — none of which can be + * tested through the Composable itself. + */ +@RunWith(RobolectricTestRunner::class) +class RoadTripScreenLogicTest { + @Test + fun `key masking never shows a usable credential`() { + assertThat(maskKey("rtota_1234567890abcdef")).isEqualTo("rtota_…cdef") + // Short enough to be a typo rather than a key: show nothing at all. + assertThat(maskKey("abc")).isEqualTo("••••") + } + + @Test + fun `privacy cycles through every level and back to account default`() { + var p = "" + val seen = mutableListOf() + repeat(5) { + p = nextPrivacy(p) + seen.add(p) + } + assertThat(seen).containsExactly("public", "delayed", "followers", "private", "") + .inOrder() + } + + @Test + fun `an unrecognised stored privacy restarts the cycle`() { + assertThat(nextPrivacy("nonsense")).isEqualTo("public") + } + + @Test + fun `the tracking summary states the real numbers`() { + val text = describeProfile(SmartBeaconProfile.DEFAULT) + assertThat(text).contains("30 s above 70 mph") + assertThat(text).contains("3 min") + assertThat(text).contains("15° + 255/mph") + assertThat(text).contains("parked") + } + + @Test + fun `notification summarises the trip`() { + val text = + RtotaTripService.notificationText( + RtotaTripState(miles = 142.34, sentQsos = 30, pendingQsos = 7, pendingPoints = 5), + ) + assertThat(text).isEqualTo("142.3 mi · 37 QSOs · 12 waiting") + } + + @Test + fun `notification stays quiet when everything is uploaded`() { + val text = RtotaTripService.notificationText(RtotaTripState(miles = 12.0, sentQsos = 3)) + assertThat(text).isEqualTo("12.0 mi · 3 QSOs") + } + + @Test + fun `notification says parked so a quiet trail does not read as broken`() { + val text = + RtotaTripService.notificationText( + RtotaTripState(miles = 88.0, sentQsos = 12, parked = true), + ) + assertThat(text).isEqualTo("88.0 mi · 12 QSOs · parked") + } + + @Test + fun `notification flags an offline start and the last error`() { + val text = + RtotaTripService.notificationText( + RtotaTripState(miles = 0.4, pendingCreate = true, lastError = "no route to host"), + ) + assertThat(text).contains("offline start") + assertThat(text).contains("no route to host") + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaBaseUrlTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaBaseUrlTest.kt new file mode 100644 index 000000000..f29bf583b --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaBaseUrlTest.kt @@ -0,0 +1,66 @@ +package radio.ks3ckc.ft8af.rtota + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Base-URL normalization. + * + * The apex case is not cosmetic: `rtota.app` answers with a 308, no HTTP client + * may auto-follow a 308 for a POST, and [isRetryableRtotaFailure] classifies a + * 3xx as fatal — so a single wrong host turns every upload of a trip into a + * permanent failure with a full queue behind it. + */ +class RtotaBaseUrlTest { + @Test + fun `the apex host is rewritten to www so POSTs are not 308ed`() { + assertThat(normalizeRtotaBaseUrl("https://rtota.app")).isEqualTo("https://www.rtota.app") + assertThat(normalizeRtotaBaseUrl("http://rtota.app")).isEqualTo("http://www.rtota.app") + assertThat(normalizeRtotaBaseUrl("https://RTOTA.APP")).isEqualTo("https://www.rtota.app") + } + + @Test + fun `the default is already the www host`() { + assertThat(RtotaSettings.DEFAULT_BASE_URL).isEqualTo("https://www.rtota.app") + assertThat(normalizeRtotaBaseUrl(RtotaSettings.DEFAULT_BASE_URL)) + .isEqualTo(RtotaSettings.DEFAULT_BASE_URL) + } + + @Test + fun `a bare host gets https rather than throwing at request time`() { + assertThat(normalizeRtotaBaseUrl("rtota.app")).isEqualTo("https://www.rtota.app") + assertThat(normalizeRtotaBaseUrl("dev.example.com")).isEqualTo("https://dev.example.com") + } + + @Test + fun `trailing slashes and whitespace are stripped so paths append cleanly`() { + assertThat(normalizeRtotaBaseUrl(" https://www.rtota.app/ ")).isEqualTo("https://www.rtota.app") + assertThat(normalizeRtotaBaseUrl("https://rtota.app///")).isEqualTo("https://www.rtota.app") + } + + @Test + fun `a self-hosted or dev origin is left exactly as typed`() { + // Nothing here knows how someone else's origin is fronted, so guessing + // would be worse than leaving it alone. + assertThat(normalizeRtotaBaseUrl("http://192.168.1.50:3000")).isEqualTo("http://192.168.1.50:3000") + assertThat(normalizeRtotaBaseUrl("https://rtota.example.org")).isEqualTo("https://rtota.example.org") + } + + @Test + fun `only the apex host matches, not a lookalike or a path`() { + assertThat(normalizeRtotaBaseUrl("https://myrtota.app")).isEqualTo("https://myrtota.app") + assertThat(normalizeRtotaBaseUrl("https://staging.rtota.app")).isEqualTo("https://staging.rtota.app") + assertThat(normalizeRtotaBaseUrl("https://example.com/rtota.app")).isEqualTo("https://example.com/rtota.app") + } + + @Test + fun `a host with a port or path keeps them while the host is fixed`() { + assertThat(normalizeRtotaBaseUrl("https://rtota.app/base")).isEqualTo("https://www.rtota.app/base") + } + + @Test + fun `empty stays empty so the caller can fall back to the default`() { + assertThat(normalizeRtotaBaseUrl("")).isEmpty() + assertThat(normalizeRtotaBaseUrl(" ")).isEmpty() + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCallsignTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCallsignTest.kt new file mode 100644 index 000000000..273ab71e1 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCallsignTest.kt @@ -0,0 +1,56 @@ +package radio.ks3ckc.ft8af.rtota + +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Test +import java.util.Locale + +/** + * Callsign normalization has to be locale-independent. + * + * The bug this guards is invisible on an English phone: `String.uppercase()` + * without a locale uses the default one, and Turkish/Azeri map ASCII "i" to "İ" + * (U+0130) rather than "I". An operator in that locale would store and register + * a callsign the server can never match, while the rest of the feature + * normalizes with `Locale.US` and disagrees with the setting that produced it. + */ +class RtotaCallsignTest { + private val original: Locale = Locale.getDefault() + + @After + fun restoreLocale() { + Locale.setDefault(original) + } + + @Test + fun `callsigns upper-case identically under a Turkish locale`() { + Locale.setDefault(Locale("tr", "TR")) + // The dotted capital İ is what the default-locale uppercase() would give. + assertThat(normalizeCallsign("ki7abc")).isEqualTo("KI7ABC") + assertThat(normalizeCallsign("ki7abc")).doesNotContain("İ") + } + + @Test + fun `the same input gives the same answer in every locale`() { + val locales = listOf(Locale.US, Locale("tr", "TR"), Locale("az", "AZ"), Locale.GERMANY) + val results = + locales.map { + Locale.setDefault(it) + normalizeCallsign(" ki7abc ") + } + assertThat(results.toSet()).containsExactly("KI7ABC") + } + + @Test + fun `surrounding whitespace is trimmed`() { + Locale.setDefault(Locale.US) + assertThat(normalizeCallsign(" k1abc\t")).isEqualTo("K1ABC") + assertThat(normalizeCallsign("")).isEmpty() + } + + @Test + fun `a portable suffix survives normalization`() { + Locale.setDefault(Locale.US) + assertThat(normalizeCallsign("k1abc/m")).isEqualTo("K1ABC/M") + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClientTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClientTest.kt new file mode 100644 index 000000000..82897fc38 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClientTest.kt @@ -0,0 +1,175 @@ +package radio.ks3ckc.ft8af.rtota + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONObject +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.IOException + +/** + * Request shape and failure classification against a stub server. What is being + * pinned here is the contract with rtota.app: the paths, the Bearer header, and + * — most importantly — which failures are worth retrying from a moving vehicle + * and which are the operator's problem to fix. + */ +@RunWith(RobolectricTestRunner::class) +class RtotaClientTest { + private lateinit var server: MockWebServer + private lateinit var baseUrl: String + + @Before + fun setUp() { + server = MockWebServer() + server.start() + baseUrl = server.url("/").toString().trimEnd('/') + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `registerOperator posts the callsign and returns the key`() = + runBlocking { + server.enqueue( + MockResponse().setBody("""{"id":"op1","callsign":"K1AF","apiKey":"rtota_abc123"}"""), + ) + val result = RtotaClient.registerOperator(baseUrl, "k1af", homeGrid = "DM79") + + assertThat(result.getOrNull()).isEqualTo("rtota_abc123") + val request = server.takeRequest() + assertThat(request.path).isEqualTo("/api/operators") + assertThat(request.method).isEqualTo("POST") + val body = JSONObject(request.body.readUtf8()) + assertThat(body.getString("callsign")).isEqualTo("K1AF") + assertThat(body.getString("homeGrid")).isEqualTo("DM79") + } + + @Test + fun `an already-registered callsign surfaces the server's message`() = + runBlocking { + server.enqueue( + MockResponse().setResponseCode(409) + .setBody("""{"error":"That callsign is already registered."}"""), + ) + val error = RtotaClient.registerOperator(baseUrl, "K1AF").exceptionOrNull() + + assertThat(error).isInstanceOf(RtotaHttpException::class.java) + assertThat((error as RtotaHttpException).serverMessage) + .isEqualTo("That callsign is already registered.") + // A 409 is the user's to resolve — retrying would fail identically forever. + assertThat(isRetryableRtotaFailure(error)).isFalse() + } + + @Test + fun `createTrip sends the bearer key and returns the handle`() = + runBlocking { + server.enqueue( + MockResponse().setResponseCode(201) + .setBody("""{"id":"trip-1","shareToken":"tok","status":"active"}"""), + ) + val handle = + RtotaClient.createTrip( + baseUrl = baseUrl, + apiKey = "rtota_key", + name = "Route 66", + startTimeMs = 1_753_970_709_000L, + privacy = "delayed", + ).getOrNull() + + assertThat(handle).isEqualTo(RtotaTripHandle("trip-1", "tok")) + val request = server.takeRequest() + assertThat(request.getHeader("Authorization")).isEqualTo("Bearer rtota_key") + val body = JSONObject(request.body.readUtf8()) + assertThat(body.getString("name")).isEqualTo("Route 66") + assertThat(body.getString("startTime")).isEqualTo("2025-07-31T14:05:09.000Z") + assertThat(body.getString("privacy")).isEqualTo("delayed") + } + + @Test + fun `sendLive posts points and qsos to the trip's live endpoint`() = + runBlocking { + server.enqueue( + MockResponse().setBody( + """{"points":{"inserted":1},"qsos":{"inserted":1,"duplicatesExisting":0,"duplicatesInBatch":0}}""", + ), + ) + val batch = + RtotaBatch( + points = listOf(TripPoint(1_753_970_709_000L, 39.0, -105.0)), + qsos = listOf(TripQso("K1AF", 1_753_970_709_000L, band = "20m", mode = "FT8")), + ) + val ack = RtotaClient.sendLive(baseUrl, "k", "trip-1", batch).getOrNull() + + assertThat(ack?.qsosInserted).isEqualTo(1) + val request = server.takeRequest() + assertThat(request.path).isEqualTo("/api/trips/trip-1/live") + val body = JSONObject(request.body.readUtf8()) + assertThat(body.getJSONArray("points").length()).isEqualTo(1) + assertThat(body.getJSONArray("qsos").getJSONObject(0).getString("callsign")) + .isEqualTo("K1AF") + } + + @Test + fun `a live batch the server accepts with no body still reports success`() = + runBlocking { + server.enqueue(MockResponse().setBody("")) + val batch = RtotaBatch(listOf(TripPoint(1L, 39.0, -105.0)), emptyList()) + assertThat(RtotaClient.sendLive(baseUrl, "k", "t", batch).isSuccess).isTrue() + } + + @Test + fun `completeTrip hits the complete endpoint`() = + runBlocking { + server.enqueue(MockResponse().setBody("""{"status":"completed"}""")) + assertThat(RtotaClient.completeTrip(baseUrl, "k", "trip-1").isSuccess).isTrue() + assertThat(server.takeRequest().path).isEqualTo("/api/trips/trip-1/complete") + } + + @Test + fun `createActivation posts the announcement`() = + runBlocking { + server.enqueue(MockResponse().setResponseCode(201).setBody("""{"id":"act-1"}""")) + val id = + RtotaClient.createActivation( + baseUrl = baseUrl, + apiKey = "k", + title = "I-70 westbound", + startTimeMs = 1_753_970_709_000L, + bands = listOf("20m", "40m"), + modes = listOf("FT8"), + ).getOrNull() + + assertThat(id).isEqualTo("act-1") + val body = JSONObject(server.takeRequest().body.readUtf8()) + assertThat(body.getString("title")).isEqualTo("I-70 westbound") + assertThat(body.getJSONArray("bands").length()).isEqualTo(2) + } + + @Test + fun `a server error is retryable, a rejected request is not`() { + assertThat(isRetryableRtotaFailure(RtotaHttpException(503, ""))).isTrue() + assertThat(isRetryableRtotaFailure(RtotaHttpException(429, ""))).isTrue() + assertThat(isRetryableRtotaFailure(IOException("no route to host"))).isTrue() + assertThat(isRetryableRtotaFailure(RtotaHttpException(401, ""))).isFalse() + assertThat(isRetryableRtotaFailure(RtotaHttpException(403, ""))).isFalse() + assertThat(isRetryableRtotaFailure(IllegalStateException("bad parse"))).isFalse() + } + + @Test + fun `backoff grows and then holds at fifteen minutes`() { + assertThat(rtotaBackoffMs(1)).isEqualTo(30_000L) + assertThat(rtotaBackoffMs(2)).isEqualTo(60_000L) + assertThat(rtotaBackoffMs(3)).isEqualTo(120_000L) + assertThat(rtotaBackoffMs(6)).isEqualTo(15 * 60_000L) + // Past the cap the wait must not keep doubling toward hours. + assertThat(rtotaBackoffMs(50)).isEqualTo(15 * 60_000L) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCqModifierTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCqModifierTest.kt new file mode 100644 index 000000000..63a651faa --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCqModifierTest.kt @@ -0,0 +1,73 @@ +package radio.ks3ckc.ft8af.rtota + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * The on-air token and the save/restore rules around it. + * + * The encodability check is the one that justifies the whole file: "RTOTA" is + * the name of the program but not a legal FT8 CQ modifier, and a token the + * message formatter silently drops produces a bare "CQ " that looks + * completely normal in the log — an on-air failure nobody notices until the + * trip is over. These assertions pin the difference. + */ +class RtotaCqModifierTest { + @Test + fun `the trip token is four letters so it survives FT8 encoding`() { + assertThat(RTOTA_CQ_MODIFIER).isEqualTo("RTOA") + assertThat(isEncodableCqModifier(RTOTA_CQ_MODIFIER)).isTrue() + } + + @Test + fun `RTOTA itself cannot be encoded`() { + // Five letters — no encoding exists in the 77-bit standard message, which + // is exactly why the token above is not simply the program's name. + assertThat(isEncodableCqModifier("RTOTA")).isFalse() + } + + @Test + fun `encodable modifiers are one to four letters or three digits`() { + assertThat(isEncodableCqModifier("P")).isTrue() + assertThat(isEncodableCqModifier("POTA")).isTrue() + assertThat(isEncodableCqModifier("DX")).isTrue() + assertThat(isEncodableCqModifier("123")).isTrue() + + assertThat(isEncodableCqModifier("12")).isFalse() + assertThat(isEncodableCqModifier("1234")).isFalse() + assertThat(isEncodableCqModifier("POTAX")).isFalse() + assertThat(isEncodableCqModifier("rtoa")).isFalse() // formatter matches uppercase only + assertThat(isEncodableCqModifier("")).isFalse() + assertThat(isEncodableCqModifier(null)).isFalse() + } + + @Test + fun `the operator's own modifier is remembered when a trip takes over`() { + assertThat(modifierToRemember("NA")).isEqualTo("NA") + assertThat(modifierToRemember("")).isEqualTo("") + assertThat(modifierToRemember(null)).isEqualTo("") + } + + @Test + fun `re-applying the trip token does not make it its own predecessor`() { + // The regression this guards: a restore that banked "RTOA" as the value to + // restore would leave the road-trip token set forever after the trip ended. + assertThat(modifierToRemember(RTOTA_CQ_MODIFIER)).isEqualTo("") + } + + @Test + fun `ending a trip restores what the operator had before it`() { + assertThat(modifierAfterTripEnd(RTOTA_CQ_MODIFIER, "NA")).isEqualTo("NA") + assertThat(modifierAfterTripEnd(RTOTA_CQ_MODIFIER, "")).isEqualTo("") + } + + @Test + fun `ending a trip leaves a POTA activation's modifier alone`() { + // A park activation started mid-trip owns the modifier now. Restoring over + // it would put the operator back on a plain CQ mid-activation, and POTA's + // own end would then hand "RTOA" back for a trip that no longer exists. + assertThat(modifierAfterTripEnd("POTA", "NA")).isNull() + assertThat(modifierAfterTripEnd("", "NA")).isNull() + assertThat(modifierAfterTripEnd(null, "NA")).isNull() + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaPayloadTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaPayloadTest.kt new file mode 100644 index 000000000..56598c5e5 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaPayloadTest.kt @@ -0,0 +1,193 @@ +package radio.ks3ckc.ft8af.rtota + +import com.google.common.truth.Truth.assertThat +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Wire-format checks for the live endpoint. Robolectric because org.json is an + * Android stub on the plain JVM classpath. + * + * The server validates every field with zod; anything these tests let through + * that the schema rejects would 400 the *whole batch*, stranding a trip's + * backlog behind one bad sample. So the interesting cases here are the ugly + * ones — a wrapping heading, a rig reporting 0 Hz, a missing report. + */ +@RunWith(RobolectricTestRunner::class) +class RtotaPayloadTest { + private val ts = 1_753_970_709_000L // 2025-07-31T14:05:09Z + + @Test + fun `iso timestamps are UTC with milliseconds`() { + assertThat(isoUtc(ts)).isEqualTo("2025-07-31T14:05:09.000Z") + } + + @Test + fun `point encodes the fields the API names`() { + val json = + TripPoint( + timestampMs = ts, + latitude = 39.7392, + longitude = -104.9903, + speedMph = 63.4, + headingDeg = 271.5, + accuracyM = 6.0, + state = "Colorado", + highway = "I-70", + ).toJson() + + assertThat(json.getString("timestamp")).isEqualTo("2025-07-31T14:05:09.000Z") + assertThat(json.getDouble("latitude")).isWithin(1e-9).of(39.7392) + assertThat(json.getDouble("speedMph")).isWithin(1e-9).of(63.4) + assertThat(json.getString("state")).isEqualTo("Colorado") + assertThat(json.getString("highway")).isEqualTo("I-70") + } + + @Test + fun `unknown optional fields are omitted rather than sent as null`() { + val json = TripPoint(timestampMs = ts, latitude = 39.0, longitude = -105.0).toJson() + assertThat(json.has("speedMph")).isFalse() + assertThat(json.has("headingDeg")).isFalse() + assertThat(json.has("state")).isFalse() + } + + @Test + fun `a heading past 360 is normalised into the accepted range`() { + val json = + TripPoint( + timestampMs = ts, + latitude = 39.0, + longitude = -105.0, + headingDeg = 725.0, + ).toJson() + assertThat(json.getDouble("headingDeg")).isWithin(1e-9).of(5.0) + } + + @Test + fun `a negative speed is dropped instead of failing the batch`() { + val json = + TripPoint( + timestampMs = ts, + latitude = 39.0, + longitude = -105.0, + speedMph = -1.0, + ).toJson() + assertThat(json.has("speedMph")).isFalse() + } + + @Test + fun `qso encodes callsign uppercase with reports`() { + val json = + TripQso( + callsign = "k1af", + timestampMs = ts, + band = "20m", + mode = "FT8", + grid = "dm79", + sentReport = "-12", + rcvdReport = "+03", + roverLat = 39.7, + roverLon = -104.9, + frequencyKhz = 14074.0, + ).toJson() + + assertThat(json.getString("callsign")).isEqualTo("K1AF") + assertThat(json.getString("sentReport")).isEqualTo("-12") + assertThat(json.getDouble("frequencyKhz")).isWithin(1e-9).of(14074.0) + } + + @Test + fun `an out-of-range frequency is dropped`() { + val json = TripQso(callsign = "K1AF", timestampMs = ts, frequencyKhz = 0.0).toJson() + assertThat(json.has("frequencyKhz")).isFalse() + } + + @Test + fun `live body carries only the arrays that have content`() { + val body = + buildLiveBody( + points = listOf(TripPoint(ts, 39.0, -105.0)), + qsos = emptyList(), + ) + val root = JSONObject(body) + assertThat(root.getJSONArray("points").length()).isEqualTo(1) + assertThat(root.has("qsos")).isFalse() + } + + @Test + fun `frequency conversion clamps to the accepted band`() { + assertThat(frequencyKhzOrNull(14_074_000L)).isWithin(1e-9).of(14074.0) + assertThat(frequencyKhzOrNull(0L)).isNull() + assertThat(frequencyKhzOrNull(50_000L)).isNull() // 50 Hz — a disconnected rig + } + + @Test + fun `reports format like ADIF and drop the no-report sentinels`() { + assertThat(formatReport(-12)).isEqualTo("-12") + assertThat(formatReport(3)).isEqualTo("+03") + assertThat(formatReport(-100)).isNull() + assertThat(formatReport(-120)).isNull() + } + + @Test + fun `adif date-time parses as UTC and rejects junk`() { + assertThat(parseAdifUtc("20250731", "140509")).isEqualTo(ts) + // ADIF allows HH:mm with no seconds. + assertThat(parseAdifUtc("20250731", "1405")).isEqualTo(ts - 9_000L) + assertThat(parseAdifUtc("", "140509")).isNull() + assertThat(parseAdifUtc("20250731", "")).isNull() + assertThat(parseAdifUtc("2025-07-31", "140509")).isNull() + } + + @Test + fun `live ack parses the server's counts`() { + val ack = + parseLiveAck( + """{"points":{"inserted":4},"qsos":{"inserted":2,"duplicatesExisting":3,"duplicatesInBatch":1}}""", + ) + assertThat(ack).isNotNull() + assertThat(ack!!.pointsInserted).isEqualTo(4) + assertThat(ack.qsosInserted).isEqualTo(2) + assertThat(ack.duplicates).isEqualTo(4) + } + + @Test + fun `live ack survives an unexpected body`() { + assertThat(parseLiveAck("not json")).isNull() + assertThat(parseLiveAck("")).isNull() + } + + @Test + fun `stored round-trip preserves a point`() { + val original = TripPoint(ts, 39.7392, -104.9903, 63.4, 271.5, 6.0, "Colorado", "I-70") + val restored = tripPointFromJson(original.toStoredJson()) + assertThat(restored).isEqualTo(original) + } + + @Test + fun `stored round-trip preserves a qso`() { + val original = + TripQso( + callsign = "K1AF", + timestampMs = ts, + band = "20m", + mode = "FT8", + grid = "DM79", + sentReport = "-12", + rcvdReport = "+03", + roverLat = 39.7, + roverLon = -104.9, + state = "Colorado", + frequencyKhz = 14074.0, + ) + assertThat(tripQsoFromJson(original.toStoredJson())).isEqualTo(original) + } + + @Test + fun `a corrupt stored row decodes to null instead of a bogus point`() { + assertThat(tripPointFromJson(JSONObject("""{"lat":39.0}"""))).isNull() + assertThat(tripQsoFromJson(JSONObject("""{"t":1}"""))).isNull() + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQsoMapperTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQsoMapperTest.kt new file mode 100644 index 000000000..36c003981 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQsoMapperTest.kt @@ -0,0 +1,162 @@ +package radio.ks3ckc.ft8af.rtota + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * QSLRecord → live QSO mapping. Exercised through the pure entry point so no + * QSLRecord (and therefore no GeneralVariables / rig layer) is needed. + */ +class RtotaQsoMapperTest { + private val now = 1_753_970_709_000L + + @Test + fun `a normal contact maps every field`() { + val qso = + RtotaQsoMapper.buildTripQso( + toCallsign = "k1af", + qsoDate = "20250731", + timeOn = "140509", + qsoDateOff = "20250731", + timeOff = "140609", + band = "20m", + mode = "FT8", + toGrid = "FN42", + sendReport = -12, + receivedReport = 3, + bandFreqHz = 14_074_000L, + roverLat = 39.7, + roverLon = -104.9, + state = "Colorado", + nowMs = now, + ) + + assertThat(qso).isNotNull() + assertThat(qso!!.callsign).isEqualTo("k1af") // uppercased at encode time + assertThat(qso.timestampMs).isEqualTo(now) + assertThat(qso.band).isEqualTo("20m") + assertThat(qso.sentReport).isEqualTo("-12") + assertThat(qso.rcvdReport).isEqualTo("+03") + assertThat(qso.frequencyKhz).isWithin(1e-9).of(14074.0) + assertThat(qso.state).isEqualTo("Colorado") + } + + @Test + fun `TIME_ON wins over TIME_OFF so live and ADIF copies dedupe against each other`() { + val qso = + RtotaQsoMapper.buildTripQso( + toCallsign = "K1AF", + qsoDate = "20250731", + timeOn = "140509", + qsoDateOff = "20250731", + timeOff = "141509", + band = null, + mode = null, + toGrid = null, + sendReport = -100, + receivedReport = -100, + bandFreqHz = 0L, + roverLat = null, + roverLon = null, + state = null, + nowMs = now, + ) + assertThat(qso!!.timestampMs).isEqualTo(now) + } + + @Test + fun `an unusable TIME_ON falls back to the off time`() { + val qso = + RtotaQsoMapper.buildTripQso( + toCallsign = "K1AF", + qsoDate = "", + timeOn = "", + qsoDateOff = "20250731", + timeOff = "140509", + band = null, + mode = null, + toGrid = null, + sendReport = -100, + receivedReport = -100, + bandFreqHz = 0L, + roverLat = null, + roverLon = null, + state = null, + nowMs = 999L, + ) + assertThat(qso!!.timestampMs).isEqualTo(now) + } + + @Test + fun `a record with no usable timestamps falls back to now`() { + val qso = + RtotaQsoMapper.buildTripQso( + toCallsign = "K1AF", + qsoDate = null, + timeOn = null, + qsoDateOff = null, + timeOff = null, + band = null, + mode = null, + toGrid = null, + sendReport = -100, + receivedReport = -100, + bandFreqHz = 0L, + roverLat = null, + roverLon = null, + state = null, + nowMs = 4242L, + ) + assertThat(qso!!.timestampMs).isEqualTo(4242L) + } + + @Test + fun `an SWL record with no reports omits them instead of sending the sentinel`() { + val qso = + RtotaQsoMapper.buildTripQso( + toCallsign = "K1AF", + qsoDate = "20250731", + timeOn = "140509", + qsoDateOff = null, + timeOff = null, + band = "20m", + mode = "FT8", + toGrid = "", + sendReport = -100, + receivedReport = -120, + bandFreqHz = 14_074_000L, + roverLat = null, + roverLon = null, + state = null, + nowMs = now, + ) + assertThat(qso!!.sentReport).isNull() + assertThat(qso.rcvdReport).isNull() + assertThat(qso.grid).isNull() + // No GPS fix yet — the contact still goes up, just without a position. + assertThat(qso.roverLat).isNull() + } + + @Test + fun `a record with no callsign is not queued at all`() { + val qso = + RtotaQsoMapper.buildTripQso( + toCallsign = " ", + qsoDate = "20250731", + timeOn = "140509", + qsoDateOff = null, + timeOff = null, + band = "20m", + mode = "FT8", + toGrid = null, + sendReport = 0, + receivedReport = 0, + bandFreqHz = 14_074_000L, + roverLat = null, + roverLon = null, + state = null, + nowMs = now, + ) + assertThat(qso).isNull() + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQueueTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQueueTest.kt new file mode 100644 index 000000000..3058da59c --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQueueTest.kt @@ -0,0 +1,146 @@ +package radio.ks3ckc.ft8af.rtota + +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +/** + * The offline outbox. The behaviours that matter are all failure-shaped: a + * process killed mid-trip must come back with its backlog, and a partial upload + * must drop exactly what the server took and not a row more. + */ +@RunWith(RobolectricTestRunner::class) +class RtotaQueueTest { + @get:Rule + val temp = TemporaryFolder() + + private val base = 1_700_000_000_000L + + private fun point(i: Int) = TripPoint(base + i * 1000L, 39.0 + i * 0.001, -105.0) + + private fun qso(i: Int) = TripQso("K1AF$i", base + i * 1000L, band = "20m", mode = "FT8") + + private fun queueFile(): File = File(temp.newFolder(), "rtota_queue.json") + + @Test + fun `pruning drops only the contacts the server reported holding`() { + val q = RtotaQueue(queueFile()) + repeat(3) { q.addQso(qso(it)) } + repeat(2) { q.addPoint(point(it)) } + + val removed = q.pruneAcknowledgedQsos(setOf(qso(0).dedupeKey(), qso(2).dedupeKey())) + + assertThat(removed).isEqualTo(2) + assertThat(q.qsoCount()).isEqualTo(1) + assertThat(q.peekBatch().qsos.single()).isEqualTo(qso(1)) + // Breadcrumbs carry no key and re-send idempotently — pruning must not + // touch them. + assertThat(q.pointCount()).isEqualTo(2) + } + + @Test + fun `pruning survives a restart, so a resumed trip does not re-send what it dropped`() { + val file = queueFile() + RtotaQueue(file).apply { + repeat(3) { addQso(qso(it)) } + pruneAcknowledgedQsos(setOf(qso(0).dedupeKey())) + } + val reloaded = RtotaQueue(file).also { it.load() } + assertThat(reloaded.qsoCount()).isEqualTo(2) + } + + @Test + fun `an unknown or empty key set removes nothing`() { + // A failed handshake must never be read as "the server has everything". + val q = RtotaQueue(queueFile()) + repeat(2) { q.addQso(qso(it)) } + assertThat(q.pruneAcknowledgedQsos(emptySet())).isEqualTo(0) + assertThat(q.pruneAcknowledgedQsos(setOf("W9XYZ|20M|FT8|2023-11-14T22:13"))).isEqualTo(0) + assertThat(q.qsoCount()).isEqualTo(2) + } + + @Test + fun `counts track what was added`() { + val q = RtotaQueue(queueFile()) + q.addPoint(point(1)) + q.addPoint(point(2)) + q.addQso(qso(1)) + assertThat(q.pointCount()).isEqualTo(2) + assertThat(q.qsoCount()).isEqualTo(1) + assertThat(q.isEmpty()).isFalse() + } + + @Test + fun `batch takes the oldest first and leaves the queue intact`() { + val q = RtotaQueue(queueFile()) + repeat(5) { q.addPoint(point(it)) } + val batch = q.peekBatch(maxPoints = 3, maxQsos = 3) + assertThat(batch.points).hasSize(3) + assertThat(batch.points.first()).isEqualTo(point(0)) + assertThat(q.pointCount()).isEqualTo(5) + } + + @Test + fun `commit removes exactly the batch and keeps what arrived during the upload`() { + val q = RtotaQueue(queueFile()) + repeat(3) { q.addPoint(point(it)) } + val batch = q.peekBatch() + // A fix that lands while the POST is in flight must survive the commit. + q.addPoint(point(99)) + q.commit(batch) + assertThat(q.pointCount()).isEqualTo(1) + assertThat(q.peekBatch().points.single()).isEqualTo(point(99)) + } + + @Test + fun `a queue survives a process restart`() { + val file = queueFile() + val first = RtotaQueue(file) + repeat(4) { first.addPoint(point(it)) } + first.addQso(qso(1)) + + val second = RtotaQueue(file).apply { load() } + assertThat(second.pointCount()).isEqualTo(4) + assertThat(second.qsoCount()).isEqualTo(1) + assertThat(second.peekBatch().qsos.single().callsign).isEqualTo("K1AF1") + } + + @Test + fun `a truncated queue file starts empty rather than throwing`() { + val file = queueFile() + file.writeText("""{"points":[{"t":1,""") + val q = RtotaQueue(file).apply { load() } + assertThat(q.isEmpty()).isTrue() + } + + @Test + fun `points are capped, dropping the oldest`() { + val q = RtotaQueue(null) + repeat(RtotaQueue.MAX_POINTS + 10) { q.addPoint(point(it)) } + assertThat(q.pointCount()).isEqualTo(RtotaQueue.MAX_POINTS) + // The head is now the 11th point ever added. + assertThat(q.peekBatch(maxPoints = 1).points.single()).isEqualTo(point(10)) + } + + @Test + fun `clear empties both halves`() { + val q = RtotaQueue(queueFile()) + q.addPoint(point(1)) + q.addQso(qso(1)) + q.clear() + assertThat(q.isEmpty()).isTrue() + } + + @Test + fun `committing more than the queue holds does not underflow`() { + val q = RtotaQueue(null) + q.addPoint(point(1)) + val oversized = RtotaBatch(listOf(point(1), point(2), point(3)), emptyList()) + q.commit(oversized) + assertThat(q.isEmpty()).isTrue() + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaSyncStateTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaSyncStateTest.kt new file mode 100644 index 000000000..17315dd36 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaSyncStateTest.kt @@ -0,0 +1,109 @@ +package radio.ks3ckc.ft8af.rtota + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * The resume handshake: the dedupe key the app computes, and the parse of what + * `GET /api/trips/:id/sync-state` answers. + * + * The key format is a contract with someone else's code — `dedupeKey()` in the + * service's lib/qso.ts. These tests are the only thing that catches the two + * halves drifting apart, and the direction of the risk is asymmetric: a key that + * fails to match merely re-sends a contact the server dedupes anyway, while a + * key that matches the wrong thing drops a QSO that never arrived. + * + * Robolectric because org.json is an Android stub on the plain JVM classpath. + */ +@RunWith(RobolectricTestRunner::class) +class RtotaSyncStateTest { + private val ts = 1_753_970_709_000L // 2025-07-31T14:05:09Z + + @Test + fun `dedupe key matches the server's callsign-band-mode-minute format`() { + val qso = TripQso(callsign = "K1ABC", timestampMs = ts, band = "20M", mode = "FT8") + // Seconds are deliberately absent: the server slices the ISO instant to the + // minute so a live-streamed QSO and its end-of-trip ADIF twin collide. + assertThat(qso.dedupeKey()).isEqualTo("K1ABC|20M|FT8|2025-07-31T14:05") + } + + @Test + fun `dedupe key uppercases and trims exactly like the server`() { + val qso = TripQso(callsign = " k1abc ", timestampMs = ts, band = "20m", mode = "ft8") + assertThat(qso.dedupeKey()).isEqualTo("K1ABC|20M|FT8|2025-07-31T14:05") + } + + @Test + fun `missing band or mode become empty fields, not dropped ones`() { + // The server joins four fields unconditionally; an app that omitted them + // would produce a three-field key that never matches anything. + val qso = TripQso(callsign = "K1ABC", timestampMs = ts, band = null, mode = null) + assertThat(qso.dedupeKey()).isEqualTo("K1ABC|||2025-07-31T14:05") + } + + @Test + fun `two contacts in the same minute on different bands are distinct`() { + val a = TripQso(callsign = "K1ABC", timestampMs = ts, band = "20M", mode = "FT8") + val b = TripQso(callsign = "K1ABC", timestampMs = ts, band = "40M", mode = "FT8") + assertThat(a.dedupeKey()).isNotEqualTo(b.dedupeKey()) + } + + @Test + fun `parses a full sync-state answer`() { + val body = + """ + { + "tripId": "trip-123", + "status": "active", + "points": {"count": 412, "firstAt": null, "lastAt": null, "lastRevision": 412}, + "qsos": { + "count": 2, + "lastAt": "2025-07-31T14:05:09.000Z", + "lastRevision": 2, + "dedupeKeys": ["K1ABC|20M|FT8|2025-07-31T14:05", "W9XYZ|40M|FT8|2025-07-31T13:58"], + "truncated": false + }, + "serverTime": "2025-07-31T14:06:00.000Z" + } + """.trimIndent() + + val sync = parseSyncState(body) + assertThat(sync).isNotNull() + assertThat(sync!!.tripId).isEqualTo("trip-123") + assertThat(sync.status).isEqualTo("active") + assertThat(sync.pointCount).isEqualTo(412) + assertThat(sync.qsoCount).isEqualTo(2) + assertThat(sync.truncated).isFalse() + assertThat(sync.qsoDedupeKeys).containsExactly( + "K1ABC|20M|FT8|2025-07-31T14:05", + "W9XYZ|40M|FT8|2025-07-31T13:58", + ) + } + + @Test + fun `a truncated key list is reported so the caller does not over-trust it`() { + val body = """{"tripId":"t","status":"active","qsos":{"count":900,"dedupeKeys":[],"truncated":true}}""" + assertThat(parseSyncState(body)!!.truncated).isTrue() + } + + @Test + fun `a body with no trip id or no body at all yields null`() { + // The flush loop treats null as "handshake unavailable" and re-sends + // everything, which is correct but must not be mistaken for "server has none". + assertThat(parseSyncState(null)).isNull() + assertThat(parseSyncState("")).isNull() + assertThat(parseSyncState("not json")).isNull() + assertThat(parseSyncState("""{"status":"active"}""")).isNull() + } + + @Test + fun `missing sections default to zero rather than throwing`() { + val sync = parseSyncState("""{"tripId":"t","status":"active"}""") + assertThat(sync).isNotNull() + assertThat(sync!!.pointCount).isEqualTo(0) + assertThat(sync.qsoCount).isEqualTo(0) + assertThat(sync.qsoDedupeKeys).isEmpty() + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconRouteFidelityTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconRouteFidelityTest.kt new file mode 100644 index 000000000..0f0b76ab2 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconRouteFidelityTest.kt @@ -0,0 +1,163 @@ +package radio.ks3ckc.ft8af.rtota + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sin + +/** + * The point of the sampler is a route line you can trust, so this test measures + * exactly that: replay a synthetic drive at 1 Hz, keep whatever SmartBeaconing + * keeps, then ask how far the *real* path ever strays from the polyline those + * points draw. + * + * The failure this guards against is the one plain interval sampling always has: + * a 30-second gap at 65 mph is 870 m of road, and if a curve happens inside it + * the drawn line cuts straight across — the trip map shows the rover driving + * through a canyon wall. Corner pegging exists to stop that, and the control + * case below shows the difference it makes. + */ +class SmartBeaconRouteFidelityTest { + private val startLat = 39.0 + private val startLon = -105.0 + + private companion object { + const val START_MS = 1_700_000_000_000L + } + + /** One second of driving: where you end up, and which way you're pointed. */ + private data class Fix(val lat: Double, val lon: Double, val headingDeg: Double, val speedMph: Double) + + /** + * Dead-reckon a 1 Hz track: cruise, then a sweeping 90° left-hand curve, + * then cruise again. Flat-earth metres→degrees is plenty over these few km. + * + * Left-hand because the turn rate is negative and the heading runs 90° + * (due east) down to 0° (due north) — counterclockwise. + */ + private fun syntheticDrive(): List { + val out = ArrayList() + var lat = startLat + var lon = startLon + var heading = 90.0 // due east + val speedMph = 65.0 + val metersPerSec = speedMph / 2.2369363 + + fun step( + seconds: Int, + turnRateDegPerSec: Double, + ) { + repeat(seconds) { + heading = (heading + turnRateDegPerSec + 360.0) % 360.0 + val rad = Math.toRadians(heading) + // Bearing 0 = north, 90 = east. + lat += (metersPerSec * cos(rad)) / 111_195.0 + lon += (metersPerSec * sin(rad)) / (111_195.0 * cos(Math.toRadians(lat))) + out.add(Fix(lat, lon, heading, speedMph)) + } + } + + step(seconds = 240, turnRateDegPerSec = 0.0) // 4 min straight + step(seconds = 30, turnRateDegPerSec = -3.0) // 30 s sweeping left onto north + step(seconds = 240, turnRateDegPerSec = 0.0) // 4 min straight again + return out + } + + private fun replay(profile: SmartBeaconProfile): List { + val sampler = SmartBeaconSampler(profile) + val kept = ArrayList() + syntheticDrive().forEachIndexed { i, f -> + val point = + TripPoint( + timestampMs = START_MS + i * 1000L, + latitude = f.lat, + longitude = f.lon, + speedMph = f.speedMph, + headingDeg = f.headingDeg, + accuracyM = 6.0, + ) + sampler.offer(point)?.let { kept.add(it.point) } + } + return kept + } + + /** + * Worst distance from any real 1 Hz position to the drawn polyline, in + * metres, measured only up to the last beacon. Road past the final point + * isn't drawn yet — that is the pending interval, not an inaccuracy — and + * including it would swamp the number this test is actually about. + */ + private fun worstDeviationMeters(kept: List): Double { + if (kept.size < 2) return Double.MAX_VALUE + val drive = syntheticDrive() + val lastCovered = ((kept.last().timestampMs - START_MS) / 1000L).toInt() + var worst = 0.0 + for (idx in 0..minOf(lastCovered, drive.size - 1)) { + val f = drive[idx] + var best = Double.MAX_VALUE + for (i in 0 until kept.size - 1) { + best = minOf(best, distanceToSegmentMeters(f.lat, f.lon, kept[i], kept[i + 1])) + } + worst = max(worst, best) + } + return worst + } + + /** + * Point-to-segment distance, done in a local metre plane anchored at the + * segment start — accurate well past the few kilometres in play here. + */ + private fun distanceToSegmentMeters( + lat: Double, + lon: Double, + a: TripPoint, + b: TripPoint, + ): Double { + val mPerLon = 111_195.0 * cos(Math.toRadians(a.latitude)) + val px = (lon - a.longitude) * mPerLon + val py = (lat - a.latitude) * 111_195.0 + val bx = (b.longitude - a.longitude) * mPerLon + val by = (b.latitude - a.latitude) * 111_195.0 + val lenSq = bx * bx + by * by + val t = if (lenSq == 0.0) 0.0 else ((px * bx + py * by) / lenSq).coerceIn(0.0, 1.0) + val dx = px - t * bx + val dy = py - t * by + return kotlin.math.sqrt(dx * dx + dy * dy) + } + + @Test + fun `the drawn route follows the real road through a sweeping curve`() { + val kept = replay(SmartBeaconProfile.DEFAULT) + val worst = worstDeviationMeters(kept) + // Roughly a highway's own width off the truth at the worst point of the + // curve, across 8.5 miles of driving. + assertThat(worst).isLessThan(40.0) + } + + @Test + fun `corner pegging is what buys that accuracy`() { + // Same drive, same rates, but a turn threshold nothing can reach: pure + // interval sampling. The curve gets cut across. + val noCorners = SmartBeaconProfile.DEFAULT.copy(minTurnAngleDeg = 180.0, turnSlope = 0.0) + val withCorners = worstDeviationMeters(replay(SmartBeaconProfile.DEFAULT)) + val without = worstDeviationMeters(replay(noCorners)) + // Printed because the whole point of the test is the measurement; the + // numbers are what justify the profile's turn settings. + println( + "worst deviation: corner-pegged=%.1f m, interval-only=%.1f m".format(withCorners, without), + ) + + assertThat(without).isGreaterThan(100.0) + assertThat(withCorners).isLessThan(without / 3.0) + } + + @Test + fun `it spends few points doing it`() { + val kept = replay(SmartBeaconProfile.DEFAULT) + // 510 fixes in (8.5 min at 1 Hz) — a couple of dozen out, most of them + // the 30 s interval on the straights plus a handful through the curve. + assertThat(kept.size).isAtMost(30) + assertThat(kept.size).isAtLeast(17) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconSamplerTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconSamplerTest.kt new file mode 100644 index 000000000..175e99fbf --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconSamplerTest.kt @@ -0,0 +1,259 @@ +package radio.ks3ckc.ft8af.rtota + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * SmartBeaconing™ route sampling — the HamHUD/APRSdroid scheme, in the shape a + * road trip needs it. + * + * The rules that matter are the ones that decide what the drawn route looks + * like: point spacing that follows speed, a point through every real turn, no + * points from a drifting compass at a standstill, and silence while parked + * (which is what lets the server derive an overnight stop). Pure math — no + * Android runtime. + */ +class SmartBeaconSamplerTest { + private val base = 1_700_000_000_000L + private val car = SmartBeaconProfile.DEFAULT + + /** + * A fix [tSec] into the trip, [metersNorth] up the road from the origin. + * One degree of latitude is ~111,195 m, which is all the geometry these + * tests need. + */ + private fun fix( + tSec: Long, + metersNorth: Double = 0.0, + speedMph: Double? = null, + heading: Double? = null, + accuracy: Double? = 8.0, + ) = TripPoint( + timestampMs = base + tSec * 1000L, + latitude = 39.0 + metersNorth / 111_195.0, + longitude = -105.0, + speedMph = speedMph, + headingDeg = heading, + accuracyM = accuracy, + ) + + // -- rate curve --------------------------------------------------------- + + @Test + fun `beacon rate is constant above the fast speed and below the slow speed`() { + assertThat(SmartBeaconSampler.beaconRateSec(80.0, car)).isEqualTo(car.fastRateSec) + assertThat(SmartBeaconSampler.beaconRateSec(70.0, car)).isEqualTo(car.fastRateSec) + assertThat(SmartBeaconSampler.beaconRateSec(1.0, car)).isEqualTo(car.slowRateSec) + } + + @Test + fun `between the thresholds the rate scales so point spacing stays even`() { + // fastRate * fastSpeed / speed: 30 s * 70 / 35 = 60 s. + assertThat(SmartBeaconSampler.beaconRateSec(35.0, car)).isEqualTo(60) + // Half the speed, twice the interval — the same ~0.6 mi between points. + assertThat(SmartBeaconSampler.beaconRateSec(17.5, car)).isEqualTo(120) + } + + @Test + fun `the rate curve never runs outside the profile's own bounds`() { + for (speed in 1..120) { + val rate = SmartBeaconSampler.beaconRateSec(speed.toDouble(), car) + assertThat(rate).isAtLeast(car.fastRateSec) + assertThat(rate).isAtMost(car.slowRateSec) + } + } + + // -- turn threshold ----------------------------------------------------- + + @Test + fun `turn threshold tightens as speed rises`() { + // 15 + 255/65 = ~18.9 degrees: an interstate curve counts. + assertThat(SmartBeaconSampler.turnThresholdDeg(65.0, car)).isWithin(0.1).of(18.9) + // 15 + 255/25 = 25.2 degrees: a town corner has to be a real one. + assertThat(SmartBeaconSampler.turnThresholdDeg(25.0, car)).isWithin(0.1).of(25.2) + // Crawling: 15 + 255/2 = 142.5 degrees — practically a U-turn. + assertThat(SmartBeaconSampler.turnThresholdDeg(2.0, car)).isWithin(0.1).of(142.5) + } + + @Test + fun `a standstill can never peg a corner`() { + assertThat(SmartBeaconSampler.turnThresholdDeg(0.0, car)).isEqualTo(180.0) + } + + // -- speed derivation --------------------------------------------------- + + @Test + fun `speed falls back to distance over time when the fix carries none`() { + val prev = fix(0) + val next = fix(60, metersNorth = 1609.0) // a mile in a minute = 60 mph + assertThat(SmartBeaconSampler.effectiveSpeedMph(prev, next, 60.0)).isWithin(1.0).of(60.0) + } + + @Test + fun `the larger of reported and derived speed wins`() { + val prev = fix(0, speedMph = 55.0) + val next = fix(10, metersNorth = 20.0, speedMph = 55.0) + // Derived is ~4 mph (a stale/lagging position); the Doppler reading is real. + assertThat(SmartBeaconSampler.effectiveSpeedMph(prev, next, 10.0)).isWithin(0.1).of(55.0) + } + + // -- end-to-end sampling ------------------------------------------------ + + @Test + fun `the first fix always starts the route`() { + val sampler = SmartBeaconSampler(car) + assertThat(sampler.offer(fix(0))?.reason).isEqualTo(BeaconReason.FIRST) + } + + @Test + fun `highway cruising yields a point about every fast-rate seconds`() { + val sampler = SmartBeaconSampler(car) + val kept = mutableListOf() + // Ten minutes at 70 mph (31.3 m/s), a fix a second, dead straight. + for (t in 0..600L) { + val p = fix(t, metersNorth = t * 31.3, speedMph = 70.0, heading = 90.0) + sampler.offer(p)?.let { kept.add(t) } + } + // 600 s / 30 s = 20 points, plus the first fix. + assertThat(kept.size).isIn(19..22) + val gaps = kept.zipWithNext { a, b -> b - a } + assertThat(gaps.all { it in 29..31 }).isTrue() + } + + @Test + fun `a curve pegs a corner between interval points`() { + val sampler = SmartBeaconSampler(car) + sampler.offer(fix(0, speedMph = 65.0, heading = 90.0)) + // 15 s later — inside the 30 s interval — the road has bent 25 degrees, + // past the ~18.9 degree threshold at this speed. + val decision = + sampler.offer( + fix(15, metersNorth = 435.0, speedMph = 65.0, heading = 115.0), + ) + assertThat(decision?.reason).isEqualTo(BeaconReason.CORNER) + } + + @Test + fun `a gentle drift on a straight highway is not a corner`() { + val sampler = SmartBeaconSampler(car) + sampler.offer(fix(0, speedMph = 65.0, heading = 90.0)) + // 6 degrees of lane-change wander, well under the threshold. + assertThat(sampler.offer(fix(15, metersNorth = 435.0, speedMph = 65.0, heading = 96.0))) + .isNull() + } + + @Test + fun `a corner needs the minimum turn time so a swerve cannot spam points`() { + val sampler = SmartBeaconSampler(car) + sampler.offer(fix(0, speedMph = 65.0, heading = 90.0)) + // Same 90-degree swing 5 s apart — under minTurnTimeSec (12). + assertThat(sampler.offer(fix(5, metersNorth = 145.0, speedMph = 65.0, heading = 180.0))) + .isNull() + } + + @Test + fun `a phone swinging its heading at a red light does not peg a corner`() { + val sampler = SmartBeaconSampler(car) + sampler.offer(fix(0, speedMph = 35.0, heading = 90.0)) + // Stopped at the light: 20 s, 3 m of GPS wander, heading swung 80 degrees. + // Android keeps reporting a bearing when stationary, so only the + // movement guard rules this out. + assertThat(sampler.offer(fix(20, metersNorth = 3.0, speedMph = 0.0, heading = 170.0))) + .isNull() + } + + @Test + fun `a parked rover records nothing, leaving the gap the server needs`() { + val sampler = SmartBeaconSampler(car) + sampler.offer(fix(0, speedMph = 30.0, heading = 90.0)) + // Eight hours in a motel lot: a fix a minute, GPS wandering a few metres, + // the compass spinning freely. + var kept = 0 + for (minute in 1..480L) { + val p = + fix( + minute * 60, + metersNorth = (minute % 3).toDouble(), + speedMph = 0.0, + heading = (minute * 47 % 360).toDouble(), + ) + if (sampler.offer(p) != null) kept++ + sampler.noteStationary(p) + } + assertThat(kept).isEqualTo(0) + assertThat(sampler.isParked).isTrue() + } + + @Test + fun `pulling back out onto the road is recorded immediately`() { + val sampler = SmartBeaconSampler(car) + sampler.offer(fix(0, speedMph = 30.0, heading = 90.0)) + for (minute in 1..300L) { + val p = fix(minute * 60, metersNorth = 2.0, speedMph = 0.0, heading = 90.0) + sampler.offer(p) + sampler.noteStationary(p) + } + assertThat(sampler.isParked).isTrue() + + val rolling = fix(18_100, metersNorth = 400.0, speedMph = 25.0, heading = 90.0) + assertThat(sampler.offer(rolling)?.reason).isEqualTo(BeaconReason.RESUME) + assertThat(sampler.isParked).isFalse() + } + + @Test + fun `inaccurate fixes never enter the route`() { + val sampler = SmartBeaconSampler(car) + assertThat(sampler.offer(fix(0, accuracy = 450.0))).isNull() + } + + @Test + fun `out-of-order fixes are discarded`() { + val sampler = SmartBeaconSampler(car) + sampler.offer(fix(100)) + assertThat(sampler.offer(fix(40, metersNorth = 5000.0))).isNull() + } + + // -- QSO anchoring ------------------------------------------------------ + + @Test + fun `a QSO mid-leg pins a route vertex where the contact happened`() { + val sampler = SmartBeaconSampler(car) + sampler.offer(fix(0, speedMph = 70.0, heading = 90.0)) + // 20 s into a 30 s interval — half a mile past the last point. + val where = fix(20, metersNorth = 626.0, speedMph = 70.0, heading = 90.0) + val anchor = sampler.anchorForQso(where) + assertThat(anchor?.reason).isEqualTo(BeaconReason.QSO) + assertThat(sampler.lastAccepted).isEqualTo(where) + } + + @Test + fun `a QSO right after a route point does not duplicate it`() { + val sampler = SmartBeaconSampler(car) + val start = fix(0, speedMph = 3.0, heading = 90.0) + sampler.offer(start) + // 5 s and ~7 m later: the existing vertex already marks the spot. + assertThat(sampler.anchorForQso(fix(5, metersNorth = 7.0))).isNull() + } + + @Test + fun `a QSO anchor counts as movement so the next leg measures from it`() { + val sampler = SmartBeaconSampler(car) + sampler.offer(fix(0, speedMph = 70.0, heading = 90.0)) + sampler.anchorForQso(fix(20, metersNorth = 626.0, speedMph = 70.0, heading = 90.0)) + assertThat(sampler.traveledMeters).isWithin(5.0).of(626.0) + } + + @Test + fun `haversine matches a known distance`() { + // One degree of latitude is ~111.2 km anywhere on the globe. + assertThat(haversineMeters(39.0, -105.0, 40.0, -105.0)).isWithin(500.0).of(111_195.0) + } + + @Test + fun `heading delta wraps across north`() { + assertThat(headingDelta(350.0, 10.0)).isWithin(0.001).of(20.0) + assertThat(headingDelta(10.0, 350.0)).isWithin(0.001).of(20.0) + assertThat(headingDelta(0.0, 180.0)).isWithin(0.001).of(180.0) + assertThat(headingDelta(90.0, 95.0)).isWithin(0.001).of(5.0) + } +} From d79065d0dabdc4d28c10381cab14d91254dd4be7 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sat, 1 Aug 2026 18:45:16 -0500 Subject: [PATCH 103/113] Show DT on every decode, as WSJT-X does (#713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Show DT on every decode, as WSJT-X does The slot bar has carried a clock-sync pill for a while, but it shows the *mean* DT across the cycle — and a mean cannot answer the question an operator actually has when it reads badly. "Every station I hear is at -1.3" means my clock is wrong; "one station is at -1.3" means his is. Those need the same fix in opposite places, and until now the app gave no way to tell them apart. The value was already there and already trusted: `Ft8Message.time_sec` is the per-decode offset, and `mutableTimerOffset.postValue(time_sec)` is what feeds the existing pill and the correction suggestion in Time Sync. This just puts it on the row it belongs to. Rendered WSJT-X style — signed, one decimal, no unit — and prefixed "DT" rather than suffixed with seconds, because the metadata row already ends in an "ago" time and a bare "-1.3 s" beside it reads as another duration. Values that round to zero render unsigned: "-0.0" is noise in a column being scanned for a sign. Amber past ±1.0 s, which is deliberately the same threshold the slot bar's pill calls the edge of "fair" — a row must not shout while the averaged indicator above it is still calm. Verified by unit test and installed on a device; the label itself is not visually confirmed, since showing it needs live signals and no radio was attached. Co-Authored-By: Claude Opus 5 (1M context) * Address Copilot review on DT visibility and threshold (PR #713) Both valid. **Narrowed to internal.** `formatDecodeDt` and `isDecodeDtNotable` have no callers outside the decode UI and its tests, and `internal` is what the neighbouring ClockSync helpers already use. Tests live in the same module, so nothing needed relaxing to keep them compiling. **Stopped repeating the threshold.** `isDecodeDtNotable` hard-coded 1.0f while its own KDoc claimed it matched `CLOCK_SYNC_FAIR_SEC` — true only by coincidence, and silently false the moment either one moved. It now shares the constant with the slot bar's pill, which is the property that actually matters: a row must never call a reading alarming while the averaged indicator above it still calls it fair. The test asserted the same literal, so it would have sailed straight through that drift. It now asserts against the constant and its neighbours instead. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../radio/ks3ckc/ft8af/ui/decode/DecodeDt.kt | 51 +++++++++++++++ .../radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt | 17 ++++- .../ks3ckc/ft8af/ui/decode/DecodeDtTest.kt | 62 +++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeDt.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeDtTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeDt.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeDt.kt new file mode 100644 index 000000000..cc5d89edd --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeDt.kt @@ -0,0 +1,51 @@ +package radio.ks3ckc.ft8af.ui.decode + +import radio.ks3ckc.ft8af.ui.components.CLOCK_SYNC_FAIR_SEC +import java.util.Locale +import kotlin.math.abs + +/** + * DT — how far into our receive window a decoded signal actually started, in + * seconds. WSJT-X shows this on every decode line, and it is the single most + * useful number for diagnosing a clock problem. + * + * Read it as a fleet, not one at a time. Other stations are mostly synced, so a + * *consistent* bias across every decode is almost certainly our own clock, while + * one outlier is just a station with a bad clock (or a signal that arrived via a + * long path). The mean of these is what drives the sync pill on the slot bar and + * the correction suggestion in Time Sync settings; showing the per-decode value + * as well is what lets an operator tell "everyone is at -1.3" (my clock) from + * "one guy is at -1.3" (his clock). + * + * FT8 tolerates roughly ±2 s before decoding collapses, so anything past about + * ±1 s is worth acting on. + */ + +/** + * A decode's DT, WSJT-X style: signed, one decimal, no unit — `-1.3`, `+0.2`, + * `0.0`. + * + * Prefixed "DT" by the caller rather than suffixed with a unit, because the + * metadata row already carries an "ago" time and a bare "-1.3 s" next to it + * reads as another duration rather than an offset. + * + * A value that rounds to zero renders unsigned: "-0.0" is noise, and an operator + * scanning a column of numbers for a sign should not have to discount it. + */ +internal fun formatDecodeDt(seconds: Float): String { + if (seconds.isNaN() || seconds.isInfinite()) return "--" + val rounded = Math.round(seconds * 10f) / 10f + if (abs(rounded) < 0.05f) return "0.0" + return String.format(Locale.US, "%+.1f", rounded) +} + +/** + * True when a decode's DT is far enough out to be worth the operator's eye. + * + * Used only to colour the label. Shares [CLOCK_SYNC_FAIR_SEC] with the slot + * bar's sync pill rather than repeating its value, so a row can never call a + * reading alarming while the averaged indicator above it still calls it fair — + * the two would otherwise be free to drift apart the moment either is retuned. + */ +internal fun isDecodeDtNotable(seconds: Float): Boolean = + !seconds.isNaN() && !seconds.isInfinite() && abs(seconds) > CLOCK_SYNC_FAIR_SEC diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt index b28a4a912..73da166bc 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt @@ -221,6 +221,16 @@ fun DecodeRow( MetaText(if (message.hasSnr()) "${message.snr} dB" else "-- dB") MetaText("${message.getFreq_hz()} Hz") + // DT, as WSJT-X shows it: how far into our RX window this signal + // started. Per-decode rather than only the averaged pill on the slot + // bar, because the two answer different questions — every station + // sitting at the same offset is our clock, one station out on its own + // is that station's. + MetaText( + text = "DT ${formatDecodeDt(message.time_sec)}", + color = if (isDecodeDtNotable(message.time_sec)) StatusWarn else TextFaint, + ) + // Distance (computed from grid) val distanceText = computeDistanceText(message) if (distanceText.isNotEmpty()) { @@ -338,10 +348,13 @@ private fun MessageLabel( * Small metadata text used in the bottom info row. */ @Composable -private fun MetaText(text: String) { +private fun MetaText( + text: String, + color: androidx.compose.ui.graphics.Color = TextFaint, +) { Text( text = text, - color = TextFaint, + color = color, fontFamily = GeistMonoFamily, fontSize = 10.5.sp, letterSpacing = 0.02.sp, diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeDtTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeDtTest.kt new file mode 100644 index 000000000..1787a2f6a --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeDtTest.kt @@ -0,0 +1,62 @@ +package radio.ks3ckc.ft8af.ui.decode + +import com.google.common.truth.Truth.assertThat +import radio.ks3ckc.ft8af.ui.components.CLOCK_SYNC_FAIR_SEC +import org.junit.Test + +/** + * The per-decode DT label. + * + * The sign is the whole point of this field — it tells an operator which way + * their clock is wrong — so the cases here are mostly about the sign surviving + * rounding, and about "-0.0" never reaching the screen. + */ +class DecodeDtTest { + @Test + fun `sign is always explicit for non-zero values`() { + assertThat(formatDecodeDt(1.3f)).isEqualTo("+1.3") + assertThat(formatDecodeDt(-1.3f)).isEqualTo("-1.3") + assertThat(formatDecodeDt(0.2f)).isEqualTo("+0.2") + assertThat(formatDecodeDt(-0.2f)).isEqualTo("-0.2") + } + + @Test + fun `values rounding to zero render unsigned`() { + // "-0.0" is noise in a column an operator is scanning for a sign. + assertThat(formatDecodeDt(0f)).isEqualTo("0.0") + assertThat(formatDecodeDt(-0.01f)).isEqualTo("0.0") + assertThat(formatDecodeDt(0.04f)).isEqualTo("0.0") + assertThat(formatDecodeDt(-0.04f)).isEqualTo("0.0") + } + + @Test + fun `rounds to one decimal like WSJT-X`() { + assertThat(formatDecodeDt(1.24f)).isEqualTo("+1.2") + assertThat(formatDecodeDt(1.26f)).isEqualTo("+1.3") + assertThat(formatDecodeDt(-2.55f)).isEqualTo("-2.5") + } + + @Test + fun `a non-finite reading degrades to a placeholder rather than crashing`() { + assertThat(formatDecodeDt(Float.NaN)).isEqualTo("--") + assertThat(formatDecodeDt(Float.POSITIVE_INFINITY)).isEqualTo("--") + assertThat(formatDecodeDt(Float.NEGATIVE_INFINITY)).isEqualTo("--") + } + + @Test + fun `notable threshold is the slot bar's fair boundary, not a copy of it`() { + // A row must not shout while the averaged pill upstream still says "fair", + // so the boundary is asserted against the shared constant rather than a + // repeated literal — a literal here would pass even after the two drifted. + assertThat(isDecodeDtNotable(CLOCK_SYNC_FAIR_SEC)).isFalse() + assertThat(isDecodeDtNotable(CLOCK_SYNC_FAIR_SEC + 0.1f)).isTrue() + assertThat(isDecodeDtNotable(-(CLOCK_SYNC_FAIR_SEC + 0.1f))).isTrue() + assertThat(isDecodeDtNotable(CLOCK_SYNC_FAIR_SEC - 0.7f)).isFalse() + } + + @Test + fun `a non-finite reading is never notable`() { + assertThat(isDecodeDtNotable(Float.NaN)).isFalse() + assertThat(isDecodeDtNotable(Float.POSITIVE_INFINITY)).isFalse() + } +} From 388f8c6ed484532fbd4cb3a07ba83db2465e2b6f Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sat, 1 Aug 2026 19:05:31 -0500 Subject: [PATCH 104/113] Name a trip from the plans already saved on rtota.app (#714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Name a trip from the plans already saved on rtota.app Tapping "Trip name" opened an empty text box, so a trip planned on the site had to be re-typed from memory — and a name that doesn't match is a trip nobody can line up with the plan that announced it. The list is of *scheduled activations*, which is what the site's plan wizard actually writes: a trip only exists once someone drives it. Read from /api/me rather than the public /api/activations, because the public listing carries only what a stranger may see, and a plan marked private or followers — the ones most likely to be a real upcoming trip — would be missing from exactly the list the operator is trying to pick from. Picking binds nothing. The server decides which plan a trip fulfils by comparing start times with twelve hours of slack either side, so the value here is that the name matches and that the operator can see, before setting off, whether starting now will inherit the privacy they chose in the wizard. Plans outside that window are still listed — driving early is normal — but say so, because the consequence is otherwise silent: a plan marked `delayed` whose privacy doesn't apply publishes a live position that was meant to lag, and nothing on screen would have mentioned it. The window check mirrors MATCH_SLACK_HOURS in the service's lib/activation-match.ts, including its assumption that an open-ended plan spans a day. It is advisory only; the server remains the decider. With no API key the row goes straight to the free-text box as before — the plans live behind that key, and an empty picker with an auth error in it explains nothing. Verified against the live service: all three of the account's plans listed, the in-window one marked, and picking it set the trip name. Co-Authored-By: Claude Opus 5 (1M context) * Address Copilot review on the plan picker (PR #714) All three valid. **A failed plan fetch no longer reports itself as the trip's problem.** The picker reused `rtota_error`, whose text is "Last error: …" — phrasing that belongs to the trip's own upload failures. An operator whose plans failed to load would have read it as the running trip being in trouble. Now has its own string that says what actually happened. **The plan list scrolls.** It was a bare forEach in a Dialog's Column, so a rover with a season of plans would have rows running off the bottom of the screen with no way to reach them — and on a picker, unreachable means unselectable. Height-capped and scrollable rather than a LazyColumn, so a short list still hugs its content instead of always claiming the cap. **The slack test now pins the boundary.** It was named for a twelve-hour rule while asserting eleven hours in and thirteen out, which would have passed just as happily against an eleven- or thirteen-hour rule — the constant was never actually tested. Now asserts the inclusive edge to the millisecond either side, plus the constant itself. The open-ended-plan test had the same weakness (37 h, where the rule is 36) and got the same treatment, though Copilot only flagged the first. Unit tests pass. The picker was verified on-device before these changes; the layout restructure is NOT visually re-confirmed, because the phone locked and needs biometric auth I can't supply. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../radio/ks3ckc/ft8af/rtota/RtotaClient.kt | 16 ++ .../radio/ks3ckc/ft8af/rtota/RtotaModels.kt | 102 ++++++++++ .../ks3ckc/ft8af/ui/rtota/RoadTripScreen.kt | 180 +++++++++++++++++- .../src/main/res/values/strings_compose.xml | 7 + .../ks3ckc/ft8af/rtota/RtotaActivationTest.kt | 142 ++++++++++++++ 5 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaActivationTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClient.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClient.kt index 03b95dc02..cdefe638f 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClient.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClient.kt @@ -167,6 +167,22 @@ object RtotaClient { } } + /** + * The operator's own planned trips, soonest first. + * + * Read from `/api/me` rather than `/api/activations`: the public listing only + * carries what a stranger may see, and a plan the operator marked `private` + * or `followers` — the ones most likely to be a real upcoming trip — would be + * missing from exactly the list they are trying to pick from. + */ + suspend fun fetchMyActivations( + baseUrl: String, + apiKey: String, + ): Result> = + withContext(Dispatchers.IO) { + request("GET", "$baseUrl/api/me", apiKey, null).map { parseMyActivations(it) } + } + suspend fun completeTrip( baseUrl: String, apiKey: String, diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaModels.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaModels.kt index ddac5d5f5..9e3bdd07f 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaModels.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaModels.kt @@ -58,6 +58,23 @@ data class TripQso( /** Trip identifiers handed back by `POST /api/trips`. */ data class RtotaTripHandle(val id: String, val shareToken: String?) +/** + * A trip the operator planned on rtota.app — what the site's plan wizard saves. + * + * The wizard writes a *scheduled activation*, not a trip: a trip only exists + * once someone actually drives it. So this is the thing to pick a trip's name + * from, and the plan's privacy choices ride along server-side when the trip + * turns out to fulfil it (see [activationMatchesNow]). + */ +data class RtotaActivation( + val id: String, + val title: String, + val startTimeMs: Long, + /** Planned finish, or null when the plan is open-ended. */ + val endTimeMs: Long?, + val detail: String?, +) + /** * What `GET /api/trips/:id/sync-state` says the server already holds — the * resume handshake for a client that has been out of touch long enough not to @@ -374,6 +391,91 @@ fun parseSyncState(body: String?): RtotaSyncState? { } } +/** + * Parse an ISO-8601 instant of the shape the API returns + * (`2026-08-04T11:00:00.000Z`), or null when it is missing or unusable. + * + * Deliberately narrow: the API always emits UTC with milliseconds, and a + * hand-rolled lenient parser would happily accept a local-time string and place + * a trip several hours from where it belongs. + */ +fun parseIsoUtc(value: String?): Long? { + val raw = value?.trim().orEmpty() + if (raw.isEmpty()) return null + return try { + val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + fmt.timeZone = TimeZone.getTimeZone("UTC") + fmt.isLenient = false + fmt.parse(raw)?.time + } catch (_: Exception) { + null + } +} + +/** + * How much slack the server allows either side of a planned window when + * deciding which activation a trip fulfils. + * + * Mirrors `MATCH_SLACK_HOURS` in the service's lib/activation-match.ts. + * Departures rarely run on time, and the app only uses this to *tell the + * operator* whether starting now will pick the plan up — the server remains the + * one that actually decides. + */ +const val ACTIVATION_MATCH_SLACK_MS = 12 * 60 * 60 * 1000L + +/** An activation with no end time is assumed to span this long, as on the server. */ +private const val ACTIVATION_DEFAULT_SPAN_MS = 24 * 60 * 60 * 1000L + +/** + * Whether a trip started at [nowMs] would fall inside [activation]'s matching + * window, and so inherit the privacy the plan wizard chose. + * + * Worth surfacing because the consequence is invisible otherwise: a trip started + * outside the window is created with the operator's *default* privacy, which for + * a plan marked `delayed` means publishing a live position that was meant to lag. + */ +fun activationMatchesNow( + activation: RtotaActivation, + nowMs: Long, +): Boolean { + val windowStart = activation.startTimeMs - ACTIVATION_MATCH_SLACK_MS + val windowEnd = + (activation.endTimeMs ?: (activation.startTimeMs + ACTIVATION_DEFAULT_SPAN_MS)) + + ACTIVATION_MATCH_SLACK_MS + return nowMs in windowStart..windowEnd +} + +/** + * Pull the operator's own planned trips out of a `GET /api/me` body, soonest + * departure first. Rows missing an id, title or start time are dropped rather + * than shown as blanks in the picker. + */ +fun parseMyActivations(body: String?): List { + if (body.isNullOrBlank()) return emptyList() + return try { + val array = JSONObject(body).optJSONArray("activations") ?: return emptyList() + buildList { + for (i in 0 until array.length()) { + val o = array.optJSONObject(i) ?: continue + val id = o.optString("id").takeIf { it.isNotEmpty() } ?: continue + val title = o.optString("title").takeIf { it.isNotEmpty() } ?: continue + val start = parseIsoUtc(o.optString("startTime")) ?: continue + add( + RtotaActivation( + id = id, + title = title, + startTimeMs = start, + endTimeMs = parseIsoUtc(o.optString("endTime")), + detail = o.optString("detail").takeIf { it.isNotEmpty() }, + ), + ) + } + }.sortedBy { it.startTimeMs } + } catch (_: Exception) { + emptyList() + } +} + /** Parse the live endpoint's response into counts for the UI. */ fun parseLiveAck(body: String?): RtotaLiveAck? { if (body.isNullOrBlank()) return null diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rtota/RoadTripScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rtota/RoadTripScreen.kt index 1754b7a95..fb25c4a87 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rtota/RoadTripScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rtota/RoadTripScreen.kt @@ -4,10 +4,14 @@ import android.Manifest import android.app.Activity import androidx.annotation.StringRes import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions @@ -38,6 +42,9 @@ import com.k1af.ft8af.R import kotlinx.coroutines.launch import radio.ks3ckc.ft8af.location.hasLocationPermission import radio.ks3ckc.ft8af.rtota.BeaconReason +import radio.ks3ckc.ft8af.rtota.RtotaActivation +import radio.ks3ckc.ft8af.rtota.RtotaHttpException +import radio.ks3ckc.ft8af.rtota.activationMatchesNow import radio.ks3ckc.ft8af.rtota.RTOTA_CQ_MODIFIER import radio.ks3ckc.ft8af.rtota.RtotaClient import radio.ks3ckc.ft8af.rtota.RtotaSettings @@ -82,6 +89,10 @@ fun RoadTripScreen(onBack: () -> Unit) { var showServerDialog by remember { mutableStateOf(false) } var showKeyDialog by remember { mutableStateOf(false) } var showTripNameDialog by remember { mutableStateOf(false) } + var showPlanPicker by remember { mutableStateOf(false) } + var plans by remember { mutableStateOf>(emptyList()) } + var plansLoading by remember { mutableStateOf(false) } + var plansError by remember { mutableStateOf(null) } var showAnnounceDialog by remember { mutableStateOf(false) } // Trip tracking needs location; ask here rather than at the moment the user @@ -150,6 +161,24 @@ fun RoadTripScreen(onBack: () -> Unit) { ) } + if (showPlanPicker) { + TripPlanPickerDialog( + plans = plans, + loading = plansLoading, + error = plansError, + nowMs = System.currentTimeMillis(), + onDismiss = { showPlanPicker = false }, + onPick = { picked -> + tripName = picked + showPlanPicker = false + }, + onTypeName = { + showPlanPicker = false + showTripNameDialog = true + }, + ) + } + if (showTripNameDialog) { RtotaTextDialog( title = stringResource(R.string.rtota_trip_name), @@ -293,7 +322,33 @@ fun RoadTripScreen(onBack: () -> Unit) { label = stringResource(R.string.rtota_trip_name), value = tripName.ifBlank { "--" }, showChevron = true, - onClick = { showTripNameDialog = true }, + onClick = { + // Straight to the free-text box when there is no key — + // the plans live behind it, and an empty picker with an + // auth error in it explains nothing. + if (RtotaSettings.apiKey.isBlank()) { + showTripNameDialog = true + return@SettingsRow + } + showPlanPicker = true + plansLoading = true + plansError = null + scope.launch { + RtotaClient.fetchMyActivations( + RtotaSettings.baseUrl, + RtotaSettings.apiKey, + ).fold( + onSuccess = { plans = it }, + onFailure = { e -> + plansError = + (e as? RtotaHttpException)?.serverMessage + ?: e.message + ?: e.javaClass.simpleName + }, + ) + plansLoading = false + } + }, ) SettingsRow( label = stringResource(R.string.rtota_privacy), @@ -491,6 +546,129 @@ private fun NoticeText(text: String) { ) } +/** + * Pick the trip's name from the plans already saved on rtota.app, or type one. + * + * The plans are *scheduled activations* — what the site's plan wizard writes — + * because a trip only exists once someone drives it. Choosing one here does not + * bind anything: the server decides which plan a trip fulfils by comparing start + * times (±12 h), so the value of picking is that the name matches the plan and + * the operator can see, before setting off, whether starting now will inherit + * the privacy they chose in the wizard. A plan outside that window is still + * listed — driving early is normal — just marked so the inheritance isn't a + * surprise. + */ +@Composable +private fun TripPlanPickerDialog( + plans: List, + loading: Boolean, + error: String?, + nowMs: Long, + onDismiss: () -> Unit, + onPick: (String) -> Unit, + onTypeName: () -> Unit, +) { + androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(BgSurface2) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.rtota_pick_plan), + color = TextPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + + when { + loading -> + Text( + text = stringResource(R.string.rtota_pick_plan_loading), + color = TextMuted, + fontSize = 13.sp, + ) + error != null -> + // Not rtota_error ("Last error: …") — that phrasing belongs to the + // trip's own upload failures, and reusing it here would report a + // failed plan fetch as though the running trip were in trouble. + Text( + text = stringResource(R.string.rtota_pick_plan_error, error), + color = StatusBad, + fontSize = 13.sp, + ) + plans.isEmpty() -> + Text( + text = stringResource(R.string.rtota_pick_plan_empty), + color = TextMuted, + fontSize = 13.sp, + ) + else -> + // Bounded and scrollable: a rover who plans a season of trips can + // have a long list, and an unconstrained Column inside a Dialog + // simply runs off the bottom of a small screen — the rows below the + // fold become unreachable, which on a *picker* means unselectable. + // Height-capped rather than a LazyColumn so the dialog still hugs a + // short list instead of always claiming the cap. + Column( + modifier = + Modifier + .heightIn(max = 320.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + plans.forEach { plan -> + val matches = activationMatchesNow(plan, nowMs) + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable { onPick(plan.title) } + .padding(vertical = 10.dp, horizontal = 12.dp), + ) { + Text(text = plan.title, color = TextPrimary, fontSize = 15.sp) + Text( + text = + if (matches) { + stringResource(R.string.rtota_plan_matches_now) + } else { + stringResource( + R.string.rtota_plan_starts, + formatPlanStart(plan.startTimeMs), + ) + }, + color = if (matches) Accent else TextFaint, + fontSize = 12.sp, + ) + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel), color = TextMuted) + } + TextButton(onClick = onTypeName) { + Text(stringResource(R.string.rtota_pick_plan_custom), color = Accent) + } + } + } + } +} + +/** Local-time "Aug 4, 11:00" for a plan's departure, so it reads as the operator's clock. */ +internal fun formatPlanStart(startMs: Long): String = + SimpleDateFormat("MMM d, HH:mm", Locale.US).format(Date(startMs)) + /** Single-field dialog shared by the callsign / server / key / trip-name rows. */ @Composable private fun RtotaTextDialog( diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index c4190c7ac..651d8e2db 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -914,6 +914,13 @@ TRACKING SmartBeaconing Last point + Use a saved plan + Loading your plans… + No plans saved on rtota.app yet. Plan one on the site, or type a name. + Type a name + Couldn\'t load your plans: %1$s + Starting now uses this plan\'s privacy settings + Starts %1$s — outside the window, so its privacy won\'t apply Calling trip start interval diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaActivationTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaActivationTest.kt new file mode 100644 index 000000000..e4febe437 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaActivationTest.kt @@ -0,0 +1,142 @@ +package radio.ks3ckc.ft8af.rtota + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Reading the operator's saved plans off `/api/me`, and deciding whether + * starting a trip now would actually pick one up. + * + * The window check mirrors `MATCH_SLACK_HOURS` in the service's + * lib/activation-match.ts. It is advisory — the server still decides — but the + * consequence of the app disagreeing is a silent one: a plan marked `delayed` + * whose privacy doesn't apply publishes a live position that was meant to lag, + * and nothing on screen would have said so. + * + * Robolectric because org.json is an Android stub on the plain JVM classpath. + */ +@RunWith(RobolectricTestRunner::class) +class RtotaActivationTest { + // 2026-08-01T22:00:00Z + private val start = 1_785_621_600_000L + private val hour = 3_600_000L + + private fun plan( + startMs: Long = start, + endMs: Long? = start + 8 * hour, + ) = RtotaActivation("id", "Shakedown", startMs, endMs, null) + + @Test + fun `parses the plans an api-me body carries, soonest first`() { + val body = + """ + { + "operator": {"callsign": "K1AF"}, + "activations": [ + {"id":"b","title":"DEFCON To Kansas City","startTime":"2026-08-10T11:00:00.000Z", + "endTime":"2026-08-11T11:00:00.000Z","detail":"return leg"}, + {"id":"a","title":"Shakedown test drive","startTime":"2026-08-01T22:00:00.000Z", + "endTime":"2026-08-02T06:00:00.000Z"} + ] + } + """.trimIndent() + + val plans = parseMyActivations(body) + assertThat(plans.map { it.title }) + .containsExactly("Shakedown test drive", "DEFCON To Kansas City") + .inOrder() + assertThat(plans[0].startTimeMs).isEqualTo(start) + assertThat(plans[1].detail).isEqualTo("return leg") + } + + @Test + fun `an open-ended plan parses with a null end`() { + val body = """{"activations":[{"id":"a","title":"Open","startTime":"2026-08-01T22:00:00.000Z"}]}""" + assertThat(parseMyActivations(body).single().endTimeMs).isNull() + } + + @Test + fun `rows missing an id, title or start are dropped rather than shown blank`() { + val body = + """ + {"activations":[ + {"title":"No id","startTime":"2026-08-01T22:00:00.000Z"}, + {"id":"b","startTime":"2026-08-01T22:00:00.000Z"}, + {"id":"c","title":"No start"}, + {"id":"d","title":"Good","startTime":"2026-08-01T22:00:00.000Z"} + ]} + """.trimIndent() + assertThat(parseMyActivations(body).map { it.title }).containsExactly("Good") + } + + @Test + fun `a missing or unusable body yields no plans rather than throwing`() { + assertThat(parseMyActivations(null)).isEmpty() + assertThat(parseMyActivations("")).isEmpty() + assertThat(parseMyActivations("not json")).isEmpty() + assertThat(parseMyActivations("""{"operator":{}}""")).isEmpty() + } + + @Test + fun `iso timestamps parse as UTC, and junk does not`() { + assertThat(parseIsoUtc("2026-08-01T22:00:00.000Z")).isEqualTo(start) + assertThat(parseIsoUtc(null)).isNull() + assertThat(parseIsoUtc("")).isNull() + // A local-time string would silently place a plan hours away. + assertThat(parseIsoUtc("2026-08-01 22:00:00")).isNull() + assertThat(parseIsoUtc("tomorrow")).isNull() + } + + @Test + fun `a trip started inside the planned window matches`() { + assertThat(activationMatchesNow(plan(), start)).isTrue() + assertThat(activationMatchesNow(plan(), start + 4 * hour)).isTrue() + } + + @Test + fun `the twelve-hour slack either side matches, mirroring the server`() { + // Departures rarely run on time, which is why the server allows this at all. + assertThat(activationMatchesNow(plan(), start - 11 * hour)).isTrue() + assertThat(activationMatchesNow(plan(), start + 8 * hour + 11 * hour)).isTrue() + } + + @Test + fun `the slack boundary itself is inclusive, to the millisecond`() { + // The server's check is `t >= windowStart && t <= windowEnd`, so exactly + // twelve hours out still matches. Asserting only an hour inside the edge + // (as the test above does) would pass just as happily against an eleven- + // or thirteen-hour rule — the boundary is the only place the constant is + // actually pinned. + val windowStart = start - ACTIVATION_MATCH_SLACK_MS + val windowEnd = start + 8 * hour + ACTIVATION_MATCH_SLACK_MS + + assertThat(activationMatchesNow(plan(), windowStart)).isTrue() + assertThat(activationMatchesNow(plan(), windowStart - 1)).isFalse() + assertThat(activationMatchesNow(plan(), windowEnd)).isTrue() + assertThat(activationMatchesNow(plan(), windowEnd + 1)).isFalse() + } + + @Test + fun `the slack is twelve hours, not some other span`() { + assertThat(ACTIVATION_MATCH_SLACK_MS).isEqualTo(12 * 60 * 60 * 1000L) + } + + @Test + fun `outside the slack does not match`() { + assertThat(activationMatchesNow(plan(), start - 13 * hour)).isFalse() + assertThat(activationMatchesNow(plan(), start + 8 * hour + 13 * hour)).isFalse() + } + + @Test + fun `an open-ended plan is assumed to span a day, as on the server`() { + val open = plan(endMs = null) + assertThat(activationMatchesNow(open, start + 20 * hour)).isTrue() + // 24 h assumed span + 12 h slack = 36 h, and the edge is inclusive. Pinned at + // the boundary rather than an hour past it, so this can't keep passing if the + // assumed span drifts. + assertThat(activationMatchesNow(open, start + 36 * hour)).isTrue() + assertThat(activationMatchesNow(open, start + 36 * hour + 1)).isFalse() + } +} From e1a289173795546f39e8ed34077feb527f382b00 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sat, 1 Aug 2026 20:36:13 -0500 Subject: [PATCH 105/113] Bump the schema version so my_lat/my_lon actually get added (fixes a crash) (#715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump the schema version so my_lat/my_lon actually get added (fixes a crash) Every logged QSO crashed the app for anyone who upgraded rather than installed clean: SQLiteException: table QSLTable has no column named my_lat at DatabaseOpr.doInsertQSLData(DatabaseOpr.java:1565) The position columns were added in two places — the CREATE TABLE and the alterTable block — but the schema version was left at 19. Those ALTERs only ever run from onCreate or onUpgrade, and onUpgrade fires solely when that number increases, so on an existing database they never executed while the INSERT went on naming the columns regardless. The shape of the bug is why nothing caught it: a fresh install takes the CREATE TABLE path and works perfectly, so it is invisible in development and in any test that starts from an empty database. It only appears on a device that already had a logbook — which is every real user, and nobody running the tests. Bumped to 20 and gave the constant a name and a comment, since the failure mode is not obvious from the call site. The regression test builds a v19-era QSLTable by hand, stamps it with the old version, lets DatabaseOpr open it, and asserts the columns arrive — the upgrade path, not the create path, because the create path passed throughout the bug. A third case asserts every column doInsertQSLData names is present after upgrade, so the next column added without a migration fails here rather than in a car. Verified by reverting the constant to 19: all three tests fail. Restored to 20: all pass, and on the affected device the database upgraded in place to version 20 with both columns present and all 564 existing QSOs intact. Co-Authored-By: Claude Opus 5 (1M context) * Address Copilot review on the upgrade test (PR #715) Both valid, both in the test rather than the fix. **The legacy database is now closed in a finally.** It was closed after an assertion that can throw, so a failing assertion leaked the handle — and on Windows an open handle keeps the file locked, which would then defeat the delete() each test does on the way out and leave the next test opening a database it believed it had created fresh. A flaky suite is a poor way to learn that. Pulled the setup into writeLegacyDatabase() rather than wrapping both copies: the second test had the same exposure through execSQL, and one helper removes the duplication and the leak together. It also means the 'the legacy table really lacks my_lat' precondition now guards both tests, where before only the first checked it. **columnsOf quotes the identifier and uses getColumnIndexOrThrow.** PRAGMA takes no bind parameters so the name has to be inlined, but it can at least be quoted. The index change is the more useful half: getColumnIndex returning -1 surfaces as getString(-1) failing with an opaque index error several frames from the cause. Re-verified the property the test exists for after restructuring it: reverting SCHEMA_VERSION to 19 fails all three, restoring 20 passes. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../com/k1af/ft8af/database/DatabaseOpr.java | 17 +- .../DatabaseOprQslTableUpgradeTest.java | 149 ++++++++++++++++++ 2 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprQslTableUpgradeTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index c8c2d4312..0b55dedbc 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -62,9 +62,24 @@ public class DatabaseOpr extends SQLiteOpenHelper { private SQLiteDatabase db; + /** + * Schema version. Bump this whenever a column is added — the {@code alterTable} + * calls in {@code createQSLTable} and friends only ever execute from {@link #onCreate} + * and {@link #onUpgrade}, and {@code onUpgrade} fires only when this number increases. + * + *

Adding a column without bumping it produces a bug that is invisible in + * development and fatal in the field: a fresh install gets the column from + * {@code CREATE TABLE} and works perfectly, while every existing install keeps the old + * table and crashes the moment something writes the new column. That is exactly what + * v20 fixes — {@code my_lat}/{@code my_lon} shipped without a bump, so + * {@code doInsertQSLData} threw "table QSLTable has no column named my_lat" on every + * logged QSO for anyone upgrading rather than installing clean. + */ + static final int SCHEMA_VERSION = 20; + public static synchronized DatabaseOpr getInstance(@Nullable Context context, @Nullable String databaseName) { if (instance == null) { - instance = new DatabaseOpr(context, databaseName, null, 19); + instance = new DatabaseOpr(context, databaseName, null, SCHEMA_VERSION); } return instance; } diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprQslTableUpgradeTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprQslTableUpgradeTest.java new file mode 100644 index 000000000..25c8aed4f --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprQslTableUpgradeTest.java @@ -0,0 +1,149 @@ +package com.k1af.ft8af.database; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import androidx.test.core.app.ApplicationProvider; + + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.io.File; +import java.util.HashSet; +import java.util.Set; + +/** + * The QSLTable upgrade path — that an existing database gains the columns + * {@link DatabaseOpr#doInsertQSLData} writes. + * + *

This exists because of a shipped crash. {@code my_lat}/{@code my_lon} were added to + * the {@code CREATE TABLE} and to the {@code alterTable} block, but the schema version was + * left at 19 — and {@code onUpgrade} (the only thing that runs those ALTERs on an existing + * database) fires solely when that number increases. The result was invisible in + * development and fatal in the field: a fresh install got the columns from CREATE TABLE + * and behaved perfectly, while every upgraded install kept the old table and died with + * "table QSLTable has no column named my_lat" on the first logged QSO. + * + *

So the meaningful test is not "does a new database have the column" — that passed + * throughout the bug. It is "does a database created by the previous version have + * it after the helper opens it", which is what {@link #openingAnOlderDatabase_addsThePositionColumns} + * checks by building a v19-era table by hand and then letting DatabaseOpr upgrade it. + */ +@RunWith(RobolectricTestRunner.class) +public class DatabaseOprQslTableUpgradeTest { + + /** The QSLTable exactly as it stood before the position columns were added. */ + private static final String LEGACY_QSL_TABLE = + "CREATE TABLE QSLTable (\n" + + "id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + + "isQSL INTEGER DEFAULT 0,\n" + + "isLotW_import INTEGER DEFAULT 0,\n" + + "isLotW_QSL INTEGER DEFAULT 0,\n" + + "synced_cloudlog INTEGER DEFAULT 0,\n" + + "synced_qrz INTEGER DEFAULT 0,\n" + + "call TEXT,\ngridsquare TEXT,\nmode TEXT,\nrst_sent TEXT,\n" + + "rst_rcvd TEXT,\nqso_date TEXT,\ntime_on TEXT,\nqso_date_off TEXT,\n" + + "time_off TEXT,\nband TEXT,\nfreq TEXT,\nstation_callsign TEXT,\n" + + "my_gridsquare TEXT,\ncomment TEXT,\nmy_sig TEXT,\nmy_sig_info TEXT,\n" + + "sig TEXT,\nsig_info TEXT)"; + + private static Set columnsOf(SQLiteDatabase db, String table) { + Set names = new HashSet<>(); + // PRAGMA takes no bind parameters, so the identifier has to be inlined; quote it + // rather than concatenating it bare. getColumnIndexOrThrow because the silent + // alternative is getString(-1), which fails as an opaque index error several + // frames from the actual cause. + String pragma = "PRAGMA table_info(\"" + table.replace("\"", "\"\"") + "\")"; + try (Cursor c = db.rawQuery(pragma, null)) { + int nameIdx = c.getColumnIndexOrThrow("name"); + while (c.moveToNext()) { + names.add(c.getString(nameIdx)); + } + } + return names; + } + + /** + * Lay down a database exactly as the previous release left it: the old table, stamped + * with the old schema version so the helper sees an upgrade rather than a create. + * + *

Closed in a finally so a failure while building it cannot leak the handle — an + * open handle keeps the file locked on Windows, which would then defeat the + * {@code delete()} each test does on the way out and leave the next test to open a + * database it believes it created fresh. + */ + private static void writeLegacyDatabase(File dbFile) { + //noinspection ResultOfMethodCallIgnored + dbFile.getParentFile().mkdirs(); + //noinspection ResultOfMethodCallIgnored + dbFile.delete(); + + SQLiteDatabase legacy = SQLiteDatabase.openOrCreateDatabase(dbFile, null); + try { + legacy.execSQL(LEGACY_QSL_TABLE); + legacy.setVersion(19); + assertThat(columnsOf(legacy, "QSLTable")).doesNotContain("my_lat"); + } finally { + legacy.close(); + } + } + + @Test + public void openingAnOlderDatabase_addsThePositionColumns() { + Context context = ApplicationProvider.getApplicationContext(); + File dbFile = context.getDatabasePath("upgrade_probe.db"); + writeLegacyDatabase(dbFile); + + DatabaseOpr opr = new DatabaseOpr(context, dbFile.getName(), null, DatabaseOpr.SCHEMA_VERSION); + try { + Set columns = columnsOf(opr.getWritableDatabase(), "QSLTable"); + assertThat(columns).contains("my_lat"); + assertThat(columns).contains("my_lon"); + // The upgrade must not have dropped and recreated the table. + assertThat(columns).contains("my_sig_info"); + } finally { + opr.close(); + //noinspection ResultOfMethodCallIgnored + dbFile.delete(); + } + } + + @Test + public void schemaVersionIsAheadOfTheReleaseThatShippedWithoutTheColumns() { + // 19 is the version that shipped my_lat in CREATE TABLE but never ran the ALTER. + // Anything at or below it leaves upgraded installs crashing on every QSO. + assertThat(DatabaseOpr.SCHEMA_VERSION).isGreaterThan(19); + } + + @Test + public void upgradedTableCarriesEveryColumnTheInsertNames() { + // Guards the next occurrence rather than just this one: doInsertQSLData writes + // these column names, so an upgraded database missing any of them crashes the + // app on the first logged QSO exactly as my_lat did. Adding a column to that + // INSERT without a migration should fail here, not in a car. + Context context = ApplicationProvider.getApplicationContext(); + File dbFile = context.getDatabasePath("upgrade_columns_probe.db"); + writeLegacyDatabase(dbFile); + + DatabaseOpr opr = new DatabaseOpr(context, dbFile.getName(), null, DatabaseOpr.SCHEMA_VERSION); + try { + Set columns = columnsOf(opr.getWritableDatabase(), "QSLTable"); + for (String required : new String[]{ + "call", "isQSL", "isLotW_import", "isLotW_QSL", "gridsquare", "mode", + "rst_sent", "rst_rcvd", "qso_date", "time_on", "qso_date_off", "time_off", + "band", "freq", "station_callsign", "my_gridsquare", "comment", + "my_sig", "my_sig_info", "sig", "sig_info", "my_lat", "my_lon"}) { + assertThat(columns).contains(required); + } + } finally { + opr.close(); + //noinspection ResultOfMethodCallIgnored + dbFile.delete(); + } + } +} From c28cb2534d63f2be0b21cb433647ebfbaf557aa2 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sat, 1 Aug 2026 22:26:45 -0500 Subject: [PATCH 106/113] Rename RTOTA to ROTA (Roads On The Air) (#716) The program is now "Roads On The Air (ROTA)" at roadsontheair.com. This renames the Kotlin package, its classes, the string resources and all user-facing copy to match. Three things deliberately keep the old spelling, because they are state that already exists on someone's phone or in someone's files: - The EncryptedSharedPreferences filename stays "rtota_prefs". Renaming it would orphan every install's API key and, worse, its in-flight trip id and queued breadcrumbs. - ADIF now emits APP_ROTA_LAT/LON but still parses APP_RTOTA_LAT/LON on import, so archived exports and logs from older builds keep their exact rover coordinates instead of falling back to the rounded MY_LAT/MY_LON. - normalizeRotaBaseUrl() now rewrites a stored rtota.app origin to the new domain, so an install configured before the rename keeps uploading instead of failing against a host we no longer serve. Two changes go beyond a search-and-replace: The on-air CQ token becomes ROTA. It was the anagram RTOA only because RTOTA is five letters and an FT8 standard message encodes a CQ modifier of at most four; ROTA fits exactly, like POTA, so the workaround is retired and the token is finally the program's own name. maskKey() now cuts the API key at the prefix separator instead of a hardcoded six characters, which was exactly the width of "rtota_". Keys minted as "rota_" are a character shorter, and the fixed width would have printed a character of the secret itself on screen. Co-authored-by: Claude Opus 5 (1M context) --- ft8af/app/src/main/AndroidManifest.xml | 6 +- .../com/k1af/ft8af/database/DatabaseOpr.java | 8 +- .../java/com/k1af/ft8af/log/AdifRecord.java | 20 +- .../java/com/k1af/ft8af/log/QSLRecord.java | 14 +- .../radio/ks3ckc/ft8af/ComposeMainActivity.kt | 12 +- .../ks3ckc/ft8af/location/RoverPosition.kt | 8 +- .../{rtota => rota}/HighwayClassifier.kt | 2 +- .../ft8af/{rtota => rota}/HighwayResolver.kt | 4 +- .../RtotaClient.kt => rota/RotaClient.kt} | 48 ++-- .../RotaCqSession.kt} | 53 ++-- .../RotaLocationTracker.kt} | 10 +- .../RtotaModels.kt => rota/RotaModels.kt} | 61 ++-- .../RotaQsoMapper.kt} | 4 +- .../RtotaQueue.kt => rota/RotaQueue.kt} | 14 +- .../RtotaSettings.kt => rota/RotaSettings.kt} | 29 +- .../RotaTripManager.kt} | 152 +++++----- .../RotaTripService.kt} | 50 ++-- .../{rtota => rota}/SmartBeaconSampler.kt | 8 +- .../ui/{rtota => rota}/RoadTripScreen.kt | 265 +++++++++--------- .../ft8af/ui/settings/SettingsScreen.kt | 6 +- .../src/main/res/values/strings_compose.xml | 126 ++++----- .../com/k1af/ft8af/log/AdifLocationTest.java | 6 +- .../com/k1af/ft8af/log/QSLRecordTest.java | 65 +++++ .../ft8af/location/RoverPositionTest.kt | 4 +- .../{rtota => rota}/HighwayCachePolicyTest.kt | 2 +- .../{rtota => rota}/HighwayClassifierTest.kt | 2 +- .../RoadTripScreenLogicTest.kt | 31 +- .../RotaActivationTest.kt} | 6 +- .../ks3ckc/ft8af/rota/RotaBaseUrlTest.kt | 85 ++++++ .../RotaCallsignTest.kt} | 4 +- .../RotaClientTest.kt} | 62 ++-- .../RotaCqModifierTest.kt} | 35 +-- .../RotaPayloadTest.kt} | 4 +- .../RotaQsoMapperTest.kt} | 16 +- .../RotaQueueTest.kt} | 38 +-- .../RotaSyncStateTest.kt} | 4 +- .../SmartBeaconRouteFidelityTest.kt | 2 +- .../{rtota => rota}/SmartBeaconSamplerTest.kt | 2 +- .../ks3ckc/ft8af/rtota/RtotaBaseUrlTest.kt | 66 ----- 39 files changed, 733 insertions(+), 601 deletions(-) rename ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/{rtota => rota}/HighwayClassifier.kt (99%) rename ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/{rtota => rota}/HighwayResolver.kt (98%) rename ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaClient.kt => rota/RotaClient.kt} (89%) rename ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaCqSession.kt => rota/RotaCqSession.kt} (69%) rename ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaLocationTracker.kt => rota/RotaLocationTracker.kt} (94%) rename ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaModels.kt => rota/RotaModels.kt} (90%) rename ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaQsoMapper.kt => rota/RotaQsoMapper.kt} (98%) rename ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaQueue.kt => rota/RotaQueue.kt} (95%) rename ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaSettings.kt => rota/RotaSettings.kt} (89%) rename ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaTripManager.kt => rota/RotaTripManager.kt} (84%) rename ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaTripService.kt => rota/RotaTripService.kt} (81%) rename ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/{rtota => rota}/SmartBeaconSampler.kt (97%) rename ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/{rtota => rota}/RoadTripScreen.kt (77%) rename ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/{rtota => rota}/HighwayCachePolicyTest.kt (99%) rename ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/{rtota => rota}/HighwayClassifierTest.kt (99%) rename ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/{rtota => rota}/RoadTripScreenLogicTest.kt (66%) rename ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaActivationTest.kt => rota/RotaActivationTest.kt} (97%) create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaBaseUrlTest.kt rename ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaCallsignTest.kt => rota/RotaCallsignTest.kt} (96%) rename ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaClientTest.kt => rota/RotaClientTest.kt} (72%) rename ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaCqModifierTest.kt => rota/RotaCqModifierTest.kt} (65%) rename ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaPayloadTest.kt => rota/RotaPayloadTest.kt} (99%) rename ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaQsoMapperTest.kt => rota/RotaQsoMapperTest.kt} (94%) rename ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaQueueTest.kt => rota/RotaQueueTest.kt} (82%) rename ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/{rtota/RtotaSyncStateTest.kt => rota/RotaSyncStateTest.kt} (98%) rename ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/{rtota => rota}/SmartBeaconRouteFidelityTest.kt (99%) rename ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/{rtota => rota}/SmartBeaconSamplerTest.kt (99%) delete mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaBaseUrlTest.kt diff --git a/ft8af/app/src/main/AndroidManifest.xml b/ft8af/app/src/main/AndroidManifest.xml index f4babb197..b42e731d6 100644 --- a/ft8af/app/src/main/AndroidManifest.xml +++ b/ft8af/app/src/main/AndroidManifest.xml @@ -29,7 +29,7 @@ (Android 14 mutes background mic without a microphone-typed foreground service). --> - @@ -105,9 +105,9 @@ android:exported="false" android:foregroundServiceType="microphone" /> - + diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 0b55dedbc..2f231e7b6 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -1509,7 +1509,7 @@ public boolean doInsertQSLData(QSLRecord record, AfterInsertQSLData afterInsertQ radio.ks3ckc.ft8af.pota.PotaSpotsRepository.parkRefFor(record.getToCallsign())); // Stamp where the operator was, for every real-time contact — not just during an - // RTOTA trip. The ADIF this ends up in is the copy that reaches rtota.app, QRZ, + // ROTA trip. The ADIF this ends up in is the copy that reaches roadsontheair.com, QRZ, // Cloudlog and LoTW, and a QSO without a position can only ever be placed at the // grid's centre by whoever reads it. // @@ -1613,11 +1613,11 @@ public boolean doInsertQSLData(QSLRecord record, AfterInsertQSLData afterInsertQ // or missing SD can never break QSO logging (AdifLogFile.logQso itself never throws). if (appendToAdifFile) { com.k1af.ft8af.log.AdifLogFile.logQso(context, record); - // RTOTA trip mode: queue the contact for the live road-trip feed. Gated on + // ROTA trip mode: queue the contact for the live road-trip feed. Gated on // the same appendToAdifFile flag as the ADIF mirror, so a bulk log import // can't inject a thousand historic QSOs into today's drive. No-op unless a - // trip is running; never throws (see RtotaTripManager.onQsoLogged). - radio.ks3ckc.ft8af.rtota.RtotaTripManager.onQsoLogged(record); + // trip is running; never throws (see RotaTripManager.onQsoLogged). + radio.ks3ckc.ft8af.rota.RotaTripManager.onQsoLogged(record); } if (afterInsertQSLData!=null){ afterInsertQSLData.doAfterInsert(false,true);//New QSL diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifRecord.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifRecord.java index e9b27fa2e..48629da74 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifRecord.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/AdifRecord.java @@ -49,11 +49,21 @@ public final class AdifRecord { *

Both are written because they serve different readers. ADIF's own location * datatype is {@code XDDD MM.MMM} — degrees and decimal minutes — which every * conventional logbook understands but which rounds to about 1.8 m and has to be - * re-parsed. rtota.app prefers these decimal twins, so a route replay gets back + * re-parsed. roadsontheair.com prefers these decimal twins, so a route replay gets back * exactly the coordinate the phone recorded rather than a rounded reconstruction. */ - public static final String APP_RTOTA_LAT = "APP_RTOTA_LAT"; - public static final String APP_RTOTA_LON = "APP_RTOTA_LON"; + public static final String APP_ROTA_LAT = "APP_ROTA_LAT"; + public static final String APP_ROTA_LON = "APP_ROTA_LON"; + + /** + * The names these fields carried before the program was renamed from Road Trips + * On The Air to Roads On The Air. Read-only: never emitted, but still parsed on + * import so ADIF exported by an older build — or sitting in someone's archive — + * keeps its exact rover coordinates instead of silently falling back to the + * rounded {@code MY_LAT}/{@code MY_LON} pair. + */ + public static final String LEGACY_APP_ROTA_LAT = "APP_RTOTA_LAT"; + public static final String LEGACY_APP_ROTA_LON = "APP_RTOTA_LON"; /** Legacy, non-conformant name for {@link #APP_QSL_MANUAL}. Read-only: never emitted. */ public static final String LEGACY_QSL_MANUAL = "QSL_MANUAL"; @@ -185,8 +195,8 @@ public String build() { if (myLat != null && myLon != null) { appendIfNotEmpty(sb, "MY_LAT", AdifFormat.location(myLat, true)); appendIfNotEmpty(sb, "MY_LON", AdifFormat.location(myLon, false)); - appendIfNotEmpty(sb, APP_RTOTA_LAT, AdifFormat.decimalDegrees(myLat)); - appendIfNotEmpty(sb, APP_RTOTA_LON, AdifFormat.decimalDegrees(myLon)); + appendIfNotEmpty(sb, APP_ROTA_LAT, AdifFormat.decimalDegrees(myLat)); + appendIfNotEmpty(sb, APP_ROTA_LON, AdifFormat.decimalDegrees(myLon)); } appendIfNotEmpty(sb, "comment", comment); sb.append("\n"); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/log/QSLRecord.java b/ft8af/app/src/main/java/com/k1af/ft8af/log/QSLRecord.java index ca79406ae..0b13681fc 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/log/QSLRecord.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/log/QSLRecord.java @@ -257,11 +257,17 @@ public QSLRecord(HashMap map) { if (map.containsKey("SIG")) sig = map.get("SIG"); if (map.containsKey("SIG_INFO")) sigInfo = map.get("SIG_INFO"); - // Operator position on import. The APP_RTOTA_ decimal pair is preferred over the + // Operator position on import. The APP_ROTA_ decimal pair is preferred over the // standard MY_LAT/MY_LON because it is what this app wrote and round-trips // exactly; the standard fields are the fallback for logs from anywhere else. - Double lat = parseAdifDecimal(map.get("APP_RTOTA_LAT")); - Double lon = parseAdifDecimal(map.get("APP_RTOTA_LON")); + // APP_RTOTA_ is the pre-rename spelling — still read so archived exports and + // logs from older builds keep their exact coordinates. + Double lat = parseAdifDecimal(map.get(AdifRecord.APP_ROTA_LAT)); + Double lon = parseAdifDecimal(map.get(AdifRecord.APP_ROTA_LON)); + if (lat == null || lon == null) { + lat = parseAdifDecimal(map.get(AdifRecord.LEGACY_APP_ROTA_LAT)); + lon = parseAdifDecimal(map.get(AdifRecord.LEGACY_APP_ROTA_LON)); + } if (lat == null || lon == null) { lat = AdifFormat.parseLocation(map.get("MY_LAT")); lon = AdifFormat.parseLocation(map.get("MY_LON")); @@ -273,7 +279,7 @@ public QSLRecord(HashMap map) { } } - /** Plain decimal-degree parse for the APP_RTOTA_ fields; null when unusable. */ + /** Plain decimal-degree parse for the APP_ROTA_ fields; null when unusable. */ private static Double parseAdifDecimal(String raw) { if (raw == null || raw.trim().isEmpty()) return null; try { diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt index c4b192012..451be4171 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt @@ -37,7 +37,7 @@ import com.k1af.ft8af.GeneralVariables import com.k1af.ft8af.MainViewModel import com.k1af.ft8af.R import radio.ks3ckc.ft8af.crash.CrashReporting -import radio.ks3ckc.ft8af.rtota.RtotaTripManager +import radio.ks3ckc.ft8af.rota.RotaTripManager import radio.ks3ckc.ft8af.sync.QsoAutoSync import radio.ks3ckc.ft8af.util.bluetoothAdapter import com.k1af.ft8af.service.RxForegroundService @@ -195,10 +195,10 @@ class ComposeMainActivity : AppCompatActivity() { syncNow("app-start") } - // RTOTA trip mode: restore an in-flight road trip (one the phone was killed or + // ROTA trip mode: restore an in-flight road trip (one the phone was killed or // rebooted in the middle of), resume GPS tracking, and push whatever queued up // offline. No-op unless trip mode is enabled and a trip is actually running. - RtotaTripManager.init(applicationContext) + RotaTripManager.init(applicationContext) // Set Compose UI — splash plays once per cold start, then crossfades into the app. setContent { @@ -427,12 +427,12 @@ class ComposeMainActivity : AppCompatActivity() { // persisted operating mode is known, rebuild them for it and sync the UI. mainViewModel.applyLoadedOperatingMode() - // Re-impose "CQ RTOA" if a road trip was running when the app closed. + // Re-impose "CQ ROTA" if a road trip was running when the app closed. // Must come after the config load above (which assigns toModifier from // the stored row) and before the POTA resume below: a park activation // started mid-trip outranks the trip, and resuming in this order lets - // POTA save RTOA as its own predecessor and hand it back when it ends. - RtotaTripManager.onConfigLoaded() + // POTA save ROTA as its own predecessor and hand it back when it ends. + RotaTripManager.onConfigLoaded() // Resume any POTA activation that was interrupted by app close PotaSessionManager.resume() diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/location/RoverPosition.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/location/RoverPosition.kt index b0835afae..171c396e6 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/location/RoverPosition.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/location/RoverPosition.kt @@ -3,7 +3,7 @@ package radio.ks3ckc.ft8af.location import android.content.Context import android.location.LocationManager import android.util.Log -import radio.ks3ckc.ft8af.rtota.RtotaTripManager +import radio.ks3ckc.ft8af.rota.RotaTripManager /** * Where a position came from, in descending order of trustworthiness. @@ -14,7 +14,7 @@ import radio.ks3ckc.ft8af.rtota.RtotaTripManager * MY_LAT/MY_LON would dress that up as a measurement. The ADIF already carries * MY_GRIDSQUARE, so anyone reading the log can make that approximation * themselves, knowing exactly what it is. When there is no fix, the QSO is - * logged without coordinates and rtota.app infers a position from the breadcrumb + * logged without coordinates and roadsontheair.com infers a position from the breadcrumb * trail instead. */ enum class RoverPositionSource { @@ -62,7 +62,7 @@ fun chooseRoverFix(candidates: List): RoverFix? = /** * The operator's position right now, for stamping onto a logged QSO. * - * Deliberately independent of RTOTA trip mode: a contact made at home, at a park, + * Deliberately independent of ROTA trip mode: a contact made at home, at a park, * or parked at a scenic overlook is worth locating too, and the ADIF that carries * it is the copy that reaches every other logbook. Trip mode is simply the best * *source* when it happens to be running. @@ -80,7 +80,7 @@ object RoverPosition { /** The freshest fix the running trip has seen, if a trip is running at all. */ private fun liveTripFix(): RoverFix? { - val point = RtotaTripManager.latestFix() ?: return null + val point = RotaTripManager.latestFix() ?: return null return RoverFix( latitude = point.latitude, longitude = point.longitude, diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/HighwayClassifier.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/HighwayClassifier.kt similarity index 99% rename from ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/HighwayClassifier.kt rename to ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/HighwayClassifier.kt index 0fcb0d6b0..27e53f344 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/HighwayClassifier.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/HighwayClassifier.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import java.util.Locale diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/HighwayResolver.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/HighwayResolver.kt similarity index 98% rename from ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/HighwayResolver.kt rename to ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/HighwayResolver.kt index 37ee46ad0..7bdf2bea5 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/HighwayResolver.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/HighwayResolver.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import android.content.Context import android.location.Address @@ -176,7 +176,7 @@ class HighwayResolver( null } onResult(name) - }, "rtota-geocode").start() + }, "rota-geocode").start() } } catch (e: Exception) { Log.d(TAG, "geocode threw: ${e.javaClass.simpleName}") diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClient.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaClient.kt similarity index 89% rename from ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClient.kt rename to ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaClient.kt index cdefe638f..9bd189f54 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClient.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaClient.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import android.util.Log import com.k1af.ft8af.GeneralVariables @@ -17,10 +17,10 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -/** Non-2xx from rtota.app, carrying the status so callers can tell retryable from fatal. */ -class RtotaHttpException(val httpCode: Int, val body: String) : +/** Non-2xx from roadsontheair.com, carrying the status so callers can tell retryable from fatal. */ +class RotaHttpException(val httpCode: Int, val body: String) : Exception("HTTP $httpCode${if (body.isNotBlank()) ": ${body.take(200)}" else ""}") { - /** The `error` field rtota.app puts in every failure body, when present. */ + /** The `error` field roadsontheair.com puts in every failure body, when present. */ val serverMessage: String? get() = try { @@ -36,9 +36,9 @@ class RtotaHttpException(val httpCode: Int, val body: String) : * request itself is wrong — a bad API key, a trip that isn't ours — and retrying * it just burns battery against the same rejection. */ -fun isRetryableRtotaFailure(error: Throwable?): Boolean = +fun isRetryableRotaFailure(error: Throwable?): Boolean = when (error) { - is RtotaHttpException -> error.httpCode == 429 || error.httpCode >= 500 + is RotaHttpException -> error.httpCode == 429 || error.httpCode >= 500 is IOException -> true else -> false } @@ -48,17 +48,17 @@ fun isRetryableRtotaFailure(error: Throwable?): Boolean = * minutes. Long by HTTP standards on purpose — the failure this handles is * usually "no cell coverage for the next 40 miles", and hammering the radio * costs battery on a device that is often navigating at the same time. A - * regained network triggers an immediate flush anyway (see [RtotaTripManager]), + * regained network triggers an immediate flush anyway (see [RotaTripManager]), * so the backoff only governs the pessimistic case. */ -fun rtotaBackoffMs(attempt: Int): Long { +fun rotaBackoffMs(attempt: Int): Long { val capped = attempt.coerceIn(1, 6) return minOf(30_000L shl (capped - 1), 15 * 60_000L) } /** - * Talks to the RTOTA API (rtota.app, or a self-hosted origin — see - * [RtotaSettings.baseUrl]). Same house style as + * Talks to the ROTA API (roadsontheair.com, or a self-hosted origin — see + * [RotaSettings.baseUrl]). Same house style as * [radio.ks3ckc.ft8af.pota.PotaClient]: HttpURLConnection only, suspend * functions on Dispatchers.IO, every call logged into debug.log. * @@ -69,16 +69,16 @@ fun rtotaBackoffMs(attempt: Int): Long { * POST /api/trips/:id/complete -> finalize * POST /api/activations -> announce a planned trip */ -object RtotaClient { - private const val TAG = "RtotaClient" - private const val USER_AGENT = "ft8af-rtota/1.0" +object RotaClient { + private const val TAG = "RotaClient" + private const val USER_AGENT = "ft8af-rota/1.0" private const val CONNECT_TIMEOUT_MS = 15_000 private const val READ_TIMEOUT_MS = 30_000 /** * Register a new rover. The server refuses a callsign that already exists * (409) rather than echoing its key, so a returning user pastes the key from - * their rtota.app dashboard instead. + * their roadsontheair.com dashboard instead. */ suspend fun registerOperator( baseUrl: String, @@ -109,7 +109,7 @@ object RtotaClient { startTimeMs: Long, notes: String? = null, privacy: String? = null, - ): Result = + ): Result = withContext(Dispatchers.IO) { val body = JSONObject().apply { @@ -123,7 +123,7 @@ object RtotaClient { val id = o.optString("id").takeIf { it.isNotEmpty() } ?: throw IllegalStateException("Server returned no trip id") - RtotaTripHandle(id, o.optString("shareToken").takeIf { it.isNotEmpty() }) + RotaTripHandle(id, o.optString("shareToken").takeIf { it.isNotEmpty() }) } } @@ -138,12 +138,12 @@ object RtotaClient { baseUrl: String, apiKey: String, tripId: String, - batch: RtotaBatch, - ): Result = + batch: RotaBatch, + ): Result = withContext(Dispatchers.IO) { val body = buildLiveBody(batch.points, batch.qsos) request("POST", "$baseUrl/api/trips/$tripId/live", apiKey, body).map { resp -> - parseLiveAck(resp) ?: RtotaLiveAck(batch.points.size, batch.qsos.size, 0) + parseLiveAck(resp) ?: RotaLiveAck(batch.points.size, batch.qsos.size, 0) } } @@ -160,7 +160,7 @@ object RtotaClient { baseUrl: String, apiKey: String, tripId: String, - ): Result = + ): Result = withContext(Dispatchers.IO) { request("GET", "$baseUrl/api/trips/$tripId/sync-state", apiKey, null).mapCatching { resp -> parseSyncState(resp) ?: throw IllegalStateException("Unparsable sync-state body") @@ -178,7 +178,7 @@ object RtotaClient { suspend fun fetchMyActivations( baseUrl: String, apiKey: String, - ): Result> = + ): Result> = withContext(Dispatchers.IO) { request("GET", "$baseUrl/api/me", apiKey, null).map { parseMyActivations(it) } } @@ -222,7 +222,7 @@ object RtotaClient { } /** - * One request. Returns the body on 2xx, a [RtotaHttpException] otherwise, or + * One request. Returns the body on 2xx, a [RotaHttpException] otherwise, or * the underlying [IOException] when the network never got there. * * The catch is broad on purpose: every failure mode here — DNS, TLS, a proxy @@ -263,7 +263,7 @@ object RtotaClient { conn.errorStream?.bufferedReader(StandardCharsets.UTF_8) ?.use { it.readText() }.orEmpty() log("$method $url -> http $code ${err.take(160)}") - return Result.failure(RtotaHttpException(code, err)) + return Result.failure(RotaHttpException(code, err)) } val resp = conn.inputStream?.bufferedReader(StandardCharsets.UTF_8) @@ -286,7 +286,7 @@ object RtotaClient { val ctx = GeneralVariables.getMainContext() ?: return val dir = ctx.getExternalFilesDir(null) ?: return val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(Date()) - FileWriter(File(dir, "debug.log"), true).use { it.append("$ts Rtota: $msg\n") } + FileWriter(File(dir, "debug.log"), true).use { it.append("$ts Rota: $msg\n") } } catch (_: Exception) { } } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCqSession.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaCqSession.kt similarity index 69% rename from ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCqSession.kt rename to ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaCqSession.kt index 2ee1a0251..21f6ff433 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCqSession.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaCqSession.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import android.util.Log import com.k1af.ft8af.GeneralVariables @@ -9,22 +9,21 @@ import java.util.Date import java.util.Locale /** - * The CQ modifier a running trip transmits: `CQ RTOA `. + * The CQ modifier a running trip transmits: `CQ ROTA `. * - * **Why not "RTOTA".** An FT8 standard message packs the CQ into the 28-bit - * `c28` field, whose reserved range encodes `CQ` plus *one to four letters* (or - * three digits) — that is the whole vocabulary the format has. `POTA` fits at - * exactly four; `RTOTA` is five and simply has no encoding. The app enforces - * this at [com.k1af.ft8af.Ft8Message] (`[0-9]{3}|[A-Z]{1,4}`), where an - * over-long modifier is dropped and the CQ goes out bare — audible, decodable, - * and silently missing the thing that made it a road-trip CQ. + * An FT8 standard message packs the CQ into the 28-bit `c28` field, whose + * reserved range encodes `CQ` plus *one to four letters* (or three digits) — + * that is the whole vocabulary the format has. The app enforces this at + * [com.k1af.ft8af.Ft8Message] (`[0-9]{3}|[A-Z]{1,4}`), where an over-long + * modifier is dropped and the CQ goes out bare: audible, decodable, and + * silently missing the thing that made it a trip CQ. * - * The alternative, free text, is capped at 13 characters and carries no grid at - * all: `CQ RTOTA KS3CKC` is 15 and does not fit, and even where it does fit no - * receiver's CQ filter treats free text as a CQ. So the on-air token is `RTOA` - * and the message stays a real standard CQ, grid included. + * `ROTA` fits at exactly four, the same as `POTA`. Until the program was renamed + * from Road Trips On The Air this token had to be the scrambled `RTOA`, because + * `RTOTA` was five letters and had no encoding at all; the rename is what let the + * on-air token finally become the program's actual name. */ -const val RTOTA_CQ_MODIFIER = "RTOA" +const val ROTA_CQ_MODIFIER = "ROTA" /** The modifier grammar an FT8 standard message can actually encode. */ private val ENCODABLE_MODIFIER = Regex("[0-9]{3}|[A-Z]{1,4}") @@ -43,26 +42,26 @@ fun isEncodableCqModifier(modifier: String?): Boolean = * * Returns "" when [current] is already ours, which is the case that matters: a * trip re-applying its modifier (a second start, a restore after the process was - * killed) must not record `RTOA` as the thing to put back — that would make the + * killed) must not record `ROTA` as the thing to put back — that would make the * road-trip token outlive the road trip, and every later CQ would keep claiming * a trip that ended. */ -fun modifierToRemember(current: String?): String = if (current == RTOTA_CQ_MODIFIER) "" else current.orEmpty() +fun modifierToRemember(current: String?): String = if (current == ROTA_CQ_MODIFIER) "" else current.orEmpty() /** * The modifier to restore when a trip ends, or null to leave it alone. * * Null means "someone else owns this now". A POTA activation started mid-trip - * saved `RTOA` and set `POTA`; if ending the trip blindly wrote the pre-trip + * saved `ROTA` and set `POTA`; if ending the trip blindly wrote the pre-trip * value back, the operator would be mid-park-activation transmitting a plain CQ, - * and POTA's own end would then restore `RTOA` on top — a token for a trip that + * and POTA's own end would then restore `ROTA` on top — a token for a trip that * is over. Deferring to whoever holds the modifier keeps both save/restore * stacks honest no matter which order they unwind in. */ fun modifierAfterTripEnd( current: String?, remembered: String, -): String? = if (current == RTOTA_CQ_MODIFIER) remembered else null +): String? = if (current == ROTA_CQ_MODIFIER) remembered else null /** * Owns `GeneralVariables.toModifier` for the duration of a trip, the same way @@ -75,31 +74,31 @@ fun modifierAfterTripEnd( * value that isn't the operator's real preference and gets overwritten moments * later. */ -object RtotaCqSession { - private const val TAG = "RtotaCqSession" +object RotaCqSession { + private const val TAG = "RotaCqSession" /** The operator's own modifier, parked here while a trip runs. */ @Volatile private var remembered: String = "" /** True while this session is the one holding the modifier. */ - val isApplied: Boolean get() = GeneralVariables.toModifier == RTOTA_CQ_MODIFIER + val isApplied: Boolean get() = GeneralVariables.toModifier == ROTA_CQ_MODIFIER /** - * Impose `RTOA` for a running trip. Idempotent: calling it again while it is + * Impose `ROTA` for a running trip. Idempotent: calling it again while it is * already applied changes nothing and, crucially, does not overwrite the * remembered preference. */ @Synchronized fun apply() { val current = GeneralVariables.toModifier - if (current == RTOTA_CQ_MODIFIER) { + if (current == ROTA_CQ_MODIFIER) { log("apply — already set") return } remembered = modifierToRemember(current) - GeneralVariables.toModifier = RTOTA_CQ_MODIFIER - log("apply modifier='$RTOTA_CQ_MODIFIER' remembered='$remembered'") + GeneralVariables.toModifier = ROTA_CQ_MODIFIER + log("apply modifier='$ROTA_CQ_MODIFIER' remembered='$remembered'") } /** Hand the modifier back when the trip ends. */ @@ -122,7 +121,7 @@ object RtotaCqSession { val ctx = GeneralVariables.getMainContext() ?: return val dir = ctx.getExternalFilesDir(null) ?: return val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(Date()) - FileWriter(File(dir, "debug.log"), true).use { it.append("$ts RtotaCq: $msg\n") } + FileWriter(File(dir, "debug.log"), true).use { it.append("$ts RotaCq: $msg\n") } } catch (_: Exception) { } } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaLocationTracker.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaLocationTracker.kt similarity index 94% rename from ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaLocationTracker.kt rename to ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaLocationTracker.kt index 889d30aaf..019b66939 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaLocationTracker.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaLocationTracker.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import android.Manifest import android.annotation.SuppressLint @@ -14,7 +14,7 @@ import radio.ks3ckc.ft8af.location.hasLocationPermission /** * Subscribes to GPS while a trip is running and hands every fix to - * [RtotaTripManager], which samples them down into route breadcrumbs. + * [RotaTripManager], which samples them down into route breadcrumbs. * * Deliberately *not* built on [com.k1af.ft8af.location.LocationSubscriber]: that * base class exists for the two toggle-driven, low-cadence consumers (grid @@ -30,7 +30,7 @@ import radio.ks3ckc.ft8af.location.hasLocationPermission * peg a corner it saw happen, and it can only place a QSO where the rover was if * a recent fix exists. */ -class RtotaLocationTracker(context: Context) { +class RotaLocationTracker(context: Context) { private val appContext = context.applicationContext private var locationManager: LocationManager? = null @@ -41,7 +41,7 @@ class RtotaLocationTracker(context: Context) { private val listener = object : LocationListener { override fun onLocationChanged(location: Location) { - RtotaTripManager.onLocationFix(location) + RotaTripManager.onLocationFix(location) } // The three no-op overrides below are abstract on API < 30, so they must @@ -111,7 +111,7 @@ class RtotaLocationTracker(context: Context) { } companion object { - private const val TAG = "RtotaLocationTracker" + private const val TAG = "RotaLocationTracker" /** Ask for a fix this often; [SmartBeaconSampler] decides what to keep. */ private const val REQUEST_INTERVAL_MS = 1_000L diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaModels.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaModels.kt similarity index 90% rename from ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaModels.kt rename to ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaModels.kt index 9e3bdd07f..5d101f6d1 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaModels.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaModels.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import org.json.JSONArray import org.json.JSONObject @@ -8,12 +8,12 @@ import java.util.Locale import java.util.TimeZone /** - * Wire model for RTOTA (Road Trips On The Air) trip mode — the road-going - * companion service at rtota.app. + * Wire model for ROTA (Roads On The Air) trip mode — the road-going + * companion service at roadsontheair.com. * * Everything here is deliberately free of Android types so the payload shapes * can be unit-tested without a device: the manager collects [TripPoint] / - * [TripQso] values, [RtotaQueue] persists them, and [buildLiveBody] renders the + * [TripQso] values, [RotaQueue] persists them, and [buildLiveBody] renders the * exact JSON `POST /api/trips/:id/live` expects. * * The server validates every field (zod schemas in lib/api/schemas.ts), so the @@ -56,17 +56,17 @@ data class TripQso( ) /** Trip identifiers handed back by `POST /api/trips`. */ -data class RtotaTripHandle(val id: String, val shareToken: String?) +data class RotaTripHandle(val id: String, val shareToken: String?) /** - * A trip the operator planned on rtota.app — what the site's plan wizard saves. + * A trip the operator planned on roadsontheair.com — what the site's plan wizard saves. * * The wizard writes a *scheduled activation*, not a trip: a trip only exists * once someone actually drives it. So this is the thing to pick a trip's name * from, and the plan's privacy choices ride along server-side when the trip * turns out to fulfil it (see [activationMatchesNow]). */ -data class RtotaActivation( +data class RotaActivation( val id: String, val title: String, val startTimeMs: Long, @@ -80,7 +80,7 @@ data class RtotaActivation( * resume handshake for a client that has been out of touch long enough not to * know what it still owes. */ -data class RtotaSyncState( +data class RotaSyncState( val tripId: String, /** "active" or "completed" — a completed trip must not be fed any more. */ val status: String, @@ -93,7 +93,7 @@ data class RtotaSyncState( ) /** What the server reports it did with a live batch. */ -data class RtotaLiveAck( +data class RotaLiveAck( val pointsInserted: Int, val qsosInserted: Int, val duplicates: Int, @@ -171,7 +171,7 @@ fun frequencyKhzOrNull(freqHz: Long): Double? { * locale-sensitive for ASCII: under a Turkish or Azeri locale, "i" upper-cases * to "İ" (U+0130), so an operator whose phone is set to Turkish would register * as KİABC, store KİABC, and never match the KIABC the server knows — while - * [RtotaClient.registerOperator] normalizes with `Locale.US` and disagrees with + * [RotaClient.registerOperator] normalizes with `Locale.US` and disagrees with * the very setting that produced it. Callsigns are ASCII by definition, so the * locale must be fixed rather than ambient. */ @@ -181,21 +181,21 @@ fun normalizeCallsign(raw: String): String = raw.trim().uppercase(Locale.US) * Clean up a base URL before it is used, repairing the two mistakes that cost a * whole trip rather than one request. * - * 1. **A bare host.** "rtota.app" typed into the server field has no scheme, so + * 1. **A bare host.** "roadsontheair.com" typed into the server field has no scheme, so * `URL()` throws and every upload fails with a MalformedURLException the * operator can do nothing with. Assume https. - * 2. **The apex host.** `rtota.app` 308-redirects to `www.rtota.app`, and a 308 + * 2. **The apex host.** `roadsontheair.com` 308-redirects to `www.roadsontheair.com`, and a 308 * must not be auto-followed for a POST (RFC 9110 §15.4.9 — the method and * body have to survive, so a client that can't guarantee that declines). * Every write here is a POST, so the apex yields a 308 that - * [isRetryableRtotaFailure] correctly classifies as fatal, stranding the trip + * [isRetryableRotaFailure] correctly classifies as fatal, stranding the trip * on the phone. Rewriting the host is the difference between a working test * run and a silent one. * - * Only the known rtota.app host is rewritten: a self-hosted origin or a dev box - * is left exactly as typed, since nothing here knows how *it* is fronted. + * Only the hosted service's own domains are rewritten: a self-hosted origin or a + * dev box is left exactly as typed, since nothing here knows how *it* is fronted. */ -fun normalizeRtotaBaseUrl(raw: String): String { +fun normalizeRotaBaseUrl(raw: String): String { val trimmed = raw.trim().trimEnd('/') if (trimmed.isEmpty()) return trimmed val withScheme = @@ -204,10 +204,15 @@ fun normalizeRtotaBaseUrl(raw: String): String { trimmed.startsWith("https://", ignoreCase = true) -> trimmed else -> "https://$trimmed" } - // Match the apex host only — not "myrtota.app", and not a path that happens - // to contain the name. - return Regex("^(https?://)rtota\\.app(?=$|[/?#])", RegexOption.IGNORE_CASE) - .replace(withScheme) { "${it.groupValues[1]}www.rtota.app" } + // Match the known hosts only — not "myroadsontheair.com", and not a path that + // happens to contain the name. The legacy rtota.app pair (the program's name + // before it became Roads On The Air) is folded in here too: an install that + // persisted the old origin before the rename would otherwise keep POSTing at + // a domain we no longer run, so rewriting on *read* silently repoints it. + return Regex( + "^(https?://)(?:www\\.)?(?:roadsontheair\\.com|rtota\\.app)(?=$|[/?#])", + RegexOption.IGNORE_CASE, + ).replace(withScheme) { "${it.groupValues[1]}www.roadsontheair.com" } } // --------------------------------------------------------------------------- @@ -351,7 +356,7 @@ fun buildLiveBody( * key the client fails to match merely re-sends a contact the server dedupes * anyway, whereas a key that matches something it shouldn't would drop a QSO * that was never delivered. That is why this mirrors the format exactly rather - * than approximating it, and why [RtotaSyncState] is only ever used to *skip* + * than approximating it, and why [RotaSyncState] is only ever used to *skip* * sends on an exact hit. */ fun TripQso.dedupeKey(): String { @@ -365,7 +370,7 @@ fun TripQso.dedupeKey(): String { } /** Parse a `GET /api/trips/:id/sync-state` body, or null when it is unusable. */ -fun parseSyncState(body: String?): RtotaSyncState? { +fun parseSyncState(body: String?): RotaSyncState? { if (body.isNullOrBlank()) return null return try { val root = JSONObject(body) @@ -378,7 +383,7 @@ fun parseSyncState(body: String?): RtotaSyncState? { keysArray.optString(i).takeIf { it.isNotEmpty() }?.let { keys.add(it) } } } - RtotaSyncState( + RotaSyncState( tripId = tripId, status = root.optString("status"), pointCount = root.optJSONObject("points")?.optInt("count", 0) ?: 0, @@ -435,7 +440,7 @@ private const val ACTIVATION_DEFAULT_SPAN_MS = 24 * 60 * 60 * 1000L * a plan marked `delayed` means publishing a live position that was meant to lag. */ fun activationMatchesNow( - activation: RtotaActivation, + activation: RotaActivation, nowMs: Long, ): Boolean { val windowStart = activation.startTimeMs - ACTIVATION_MATCH_SLACK_MS @@ -450,7 +455,7 @@ fun activationMatchesNow( * departure first. Rows missing an id, title or start time are dropped rather * than shown as blanks in the picker. */ -fun parseMyActivations(body: String?): List { +fun parseMyActivations(body: String?): List { if (body.isNullOrBlank()) return emptyList() return try { val array = JSONObject(body).optJSONArray("activations") ?: return emptyList() @@ -461,7 +466,7 @@ fun parseMyActivations(body: String?): List { val title = o.optString("title").takeIf { it.isNotEmpty() } ?: continue val start = parseIsoUtc(o.optString("startTime")) ?: continue add( - RtotaActivation( + RotaActivation( id = id, title = title, startTimeMs = start, @@ -477,13 +482,13 @@ fun parseMyActivations(body: String?): List { } /** Parse the live endpoint's response into counts for the UI. */ -fun parseLiveAck(body: String?): RtotaLiveAck? { +fun parseLiveAck(body: String?): RotaLiveAck? { if (body.isNullOrBlank()) return null return try { val root = JSONObject(body) val points = root.optJSONObject("points") val qsos = root.optJSONObject("qsos") - RtotaLiveAck( + RotaLiveAck( pointsInserted = points?.optInt("inserted", 0) ?: 0, qsosInserted = qsos?.optInt("inserted", 0) ?: 0, duplicates = diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQsoMapper.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaQsoMapper.kt similarity index 98% rename from ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQsoMapper.kt rename to ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaQsoMapper.kt index 851a02963..8b75e228f 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQsoMapper.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaQsoMapper.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import com.k1af.ft8af.log.QSLRecord @@ -10,7 +10,7 @@ import com.k1af.ft8af.log.QSLRecord * [buildTripQso] is pure and takes the fields as plain values, and * [tripQsoFromRecord] is the thin adapter the QSO save path calls. */ -object RtotaQsoMapper { +object RotaQsoMapper { /** * @param roverLat/[roverLon] where the rover was — the last accepted GPS * breadcrumb. Omitted when tracking hasn't produced a fix yet; the diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQueue.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaQueue.kt similarity index 95% rename from ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQueue.kt rename to ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaQueue.kt index 359f3dc01..37bd979c6 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQueue.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaQueue.kt @@ -1,18 +1,18 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import org.json.JSONArray import org.json.JSONObject import java.io.File /** One batch handed to the uploader, plus how much of the queue it covers. */ -data class RtotaBatch(val points: List, val qsos: List) { +data class RotaBatch(val points: List, val qsos: List) { val isEmpty: Boolean get() = points.isEmpty() && qsos.isEmpty() val size: Int get() = points.size + qsos.size } /** * The outbox for trip mode: breadcrumbs and contacts wait here until they reach - * rtota.app, and survive a process death while they wait. + * roadsontheair.com, and survive a process death while they wait. * * Roving means hours out of coverage. Everything the app records goes into this * queue first and is only dropped once the server has acknowledged it, so a @@ -27,7 +27,7 @@ data class RtotaBatch(val points: List, val qsos: List) { * * [file] may be null in tests that only exercise the in-memory behaviour. */ -class RtotaQueue(private val file: File?) { +class RotaQueue(private val file: File?) { private val points = ArrayList() private val qsos = ArrayList() @@ -76,8 +76,8 @@ class RtotaQueue(private val file: File?) { fun peekBatch( maxPoints: Int = BATCH_POINTS, maxQsos: Int = BATCH_QSOS, - ): RtotaBatch = - RtotaBatch( + ): RotaBatch = + RotaBatch( points = points.take(maxPoints), qsos = qsos.take(maxQsos), ) @@ -89,7 +89,7 @@ class RtotaQueue(private val file: File?) { * the upload sits safely behind it. */ @Synchronized - fun commit(batch: RtotaBatch) { + fun commit(batch: RotaBatch) { repeat(minOf(batch.points.size, points.size)) { points.removeAt(0) } repeat(minOf(batch.qsos.size, qsos.size)) { qsos.removeAt(0) } persist() diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaSettings.kt similarity index 89% rename from ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaSettings.kt rename to ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaSettings.kt index 6e803f418..bd13dc7db 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaSettings.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import android.content.Context import android.content.SharedPreferences @@ -8,7 +8,7 @@ import androidx.security.crypto.MasterKey import com.k1af.ft8af.GeneralVariables /** - * Persistent configuration for RTOTA trip mode. + * Persistent configuration for ROTA trip mode. * * Kept in its own Keystore-backed [EncryptedSharedPreferences] file rather than * the app's `config` table because the API key is a standing upload credential @@ -20,7 +20,14 @@ import com.k1af.ft8af.GeneralVariables * gets its process killed 300 miles into a trip must come back up still attached * to the same trip id, with its queued breadcrumbs intact. */ -object RtotaSettings { +object RotaSettings { + /** + * Deliberately still the pre-rename name. This is the on-disk + * EncryptedSharedPreferences filename, so renaming it alongside the + * Roads-On-The-Air rebrand would orphan every existing install's API key and + * — worse — its in-flight trip id and queued breadcrumbs. A cosmetic rename + * is not worth stranding someone 300 miles into a trip. + */ private const val PREFS = "rtota_prefs" private const val KEY_ENABLED = "enabled" @@ -39,16 +46,16 @@ object RtotaSettings { /** * The hosted service; overridable for self-hosting or a dev box. * - * The `www.` host is deliberate and load-bearing. The apex `rtota.app` answers + * The `www.` host is deliberate and load-bearing. The apex `roadsontheair.com` answers * every request with a 308 to `www.`, and a 308 is precisely the redirect no * HTTP client may follow for a POST without being told to (RFC 9110: the * method and body must be preserved, so clients decline rather than guess). * Every write this app makes is a POST, so pointing at the apex turns trip - * creation into a permanent, non-retryable failure. See [normalizeRtotaBaseUrl], + * creation into a permanent, non-retryable failure. See [normalizeRotaBaseUrl], * which repairs an apex URL typed into the server-override field for the same * reason. */ - const val DEFAULT_BASE_URL = "https://www.rtota.app" + const val DEFAULT_BASE_URL = "https://www.roadsontheair.com" val PRIVACY_LEVELS = listOf("public", "delayed", "followers", "private") @@ -125,14 +132,16 @@ object RtotaSettings { /** * Service origin, normalized so callers can append paths. Normalizing on * *read* rather than only on write is what rescues the value an earlier build - * already persisted — an install that stored the apex `https://rtota.app` - * keeps working after an update instead of 308-ing forever. + * already persisted — an install that stored the apex `https://roadsontheair.com` + * keeps working after an update instead of 308-ing forever, and one that + * stored the pre-rename `rtota.app` origin gets repointed at the current + * domain rather than failing against a host we no longer serve. */ var baseUrl: String get() = - normalizeRtotaBaseUrl(readString(KEY_BASE_URL, DEFAULT_BASE_URL)) + normalizeRotaBaseUrl(readString(KEY_BASE_URL, DEFAULT_BASE_URL)) .ifEmpty { DEFAULT_BASE_URL } - set(value) = writeString(KEY_BASE_URL, normalizeRtotaBaseUrl(value)) + set(value) = writeString(KEY_BASE_URL, normalizeRotaBaseUrl(value)) var apiKey: String get() = readString(KEY_API_KEY, "") diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaTripManager.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaTripManager.kt similarity index 84% rename from ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaTripManager.kt rename to ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaTripManager.kt index dc1fcb47c..fa63bf747 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaTripManager.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaTripManager.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import android.content.Context import android.location.Location @@ -30,7 +30,7 @@ import java.util.Date import java.util.Locale /** Everything the trip UI (and the service notification) renders. */ -data class RtotaTripState( +data class RotaTripState( val active: Boolean = false, val tripId: String = "", val tripName: String = "", @@ -55,15 +55,15 @@ data class RtotaTripState( ) /** - * Trip mode: streams the rover's route and contacts to rtota.app while driving. + * Trip mode: streams the rover's route and contacts to roadsontheair.com while driving. * * Responsibilities, in the order they matter on the road: * 1. Record. Every accepted GPS breadcrumb and every logged QSO lands in - * [RtotaQueue] (on disk) before anything is attempted over the network. + * [RotaQueue] (on disk) before anything is attempted over the network. * 2. Deliver. A single-flight flush drains the queue in batches whenever there * is something to send, backing off on failure and flushing immediately when * connectivity returns. - * 3. Survive. Trip identity lives in [RtotaSettings], so a reboot or a killed + * 3. Survive. Trip identity lives in [RotaSettings], so a reboot or a killed * process resumes the same trip rather than orphaning it. * * A trip can be started *and* ended with no coverage at all: creation and @@ -72,21 +72,21 @@ data class RtotaTripState( * The object is a singleton because its two producers — the location tracker and * the QSO save path deep in DatabaseOpr — have no shared owner to hang it off. */ -object RtotaTripManager { - private const val TAG = "RtotaTripManager" - private const val QUEUE_FILE = "rtota_queue.json" +object RotaTripManager { + private const val TAG = "RotaTripManager" + private const val QUEUE_FILE = "rota_queue.json" private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val flushLock = Mutex() - private val _state = MutableStateFlow(RtotaTripState()) - val state: StateFlow = _state.asStateFlow() + private val _state = MutableStateFlow(RotaTripState()) + val state: StateFlow = _state.asStateFlow() @Volatile private var appContext: Context? = null @Volatile - private var queue: RtotaQueue? = null + private var queue: RotaQueue? = null /** Names the road for each breadcrumb; null until [init] has a context. */ @Volatile @@ -111,9 +111,9 @@ object RtotaTripManager { * running, this is by far the best source available, since it is a live fix the * app is already paying for. */ - fun latestFix(): TripPoint? = if (RtotaSettings.hasActiveTrip) lastRawFix else null + fun latestFix(): TripPoint? = if (RotaSettings.hasActiveTrip) lastRawFix else null - /** Consecutive failed flushes — drives [rtotaBackoffMs]. */ + /** Consecutive failed flushes — drives [rotaBackoffMs]. */ @Volatile private var failedAttempts = 0 @@ -146,7 +146,7 @@ object RtotaTripManager { val ctx = context.applicationContext if (appContext == null) { appContext = ctx - queue = RtotaQueue(File(ctx.filesDir, QUEUE_FILE)).also { it.load() } + queue = RotaQueue(File(ctx.filesDir, QUEUE_FILE)).also { it.load() } highwayResolver = HighwayResolver(ctx) } registerConnectivity(ctx) @@ -163,14 +163,14 @@ object RtotaTripManager { * The caller invokes this from the config-loaded callback instead. */ fun onConfigLoaded() { - if (!RtotaSettings.enabled || !RtotaSettings.hasActiveTrip) return - RtotaCqSession.apply() + if (!RotaSettings.enabled || !RotaSettings.hasActiveTrip) return + RotaCqSession.apply() } /** Re-derive UI state from what was persisted, and resume delivery. */ private fun restore() { - if (!RtotaSettings.enabled) return - if (!RtotaSettings.hasActiveTrip) { + if (!RotaSettings.enabled) return + if (!RotaSettings.hasActiveTrip) { publish() return } @@ -182,19 +182,19 @@ object RtotaTripManager { highwayResolver?.reset() lastRawFix = null // Ask the server what it has before re-sending a queue we can't account for. - resumeHandshakePending = RtotaSettings.tripId.isNotEmpty() + resumeHandshakePending = RotaSettings.tripId.isNotEmpty() _state.value = _state.value.copy( active = true, - tripId = RtotaSettings.tripId, - tripName = RtotaSettings.tripName, - startedMs = RtotaSettings.tripStartedMs, - shareToken = RtotaSettings.tripShareToken, - pendingCreate = RtotaSettings.tripPendingCreate, + tripId = RotaSettings.tripId, + tripName = RotaSettings.tripName, + startedMs = RotaSettings.tripStartedMs, + shareToken = RotaSettings.tripShareToken, + pendingCreate = RotaSettings.tripPendingCreate, ) publish() log( - "restored trip id=${RtotaSettings.tripId.ifEmpty { "(pending)" }} " + + "restored trip id=${RotaSettings.tripId.ifEmpty { "(pending)" }} " + "queued=${queue?.pointCount() ?: 0}pts/${queue?.qsoCount() ?: 0}qsos", ) startTracking() @@ -215,18 +215,18 @@ object RtotaTripManager { privacy: String?, notes: String? = null, ) { - if (RtotaSettings.hasActiveTrip) { + if (RotaSettings.hasActiveTrip) { log("startTrip ignored — a trip is already running") return } val startedMs = System.currentTimeMillis() - RtotaSettings.tripName = name.trim().ifEmpty { defaultTripName(startedMs) } - RtotaSettings.tripStartedMs = startedMs - RtotaSettings.tripPrivacy = privacy.orEmpty() - RtotaSettings.tripPendingCreate = true - RtotaSettings.tripPendingComplete = false - RtotaSettings.tripId = "" - RtotaSettings.tripShareToken = "" + RotaSettings.tripName = name.trim().ifEmpty { defaultTripName(startedMs) } + RotaSettings.tripStartedMs = startedMs + RotaSettings.tripPrivacy = privacy.orEmpty() + RotaSettings.tripPendingCreate = true + RotaSettings.tripPendingComplete = false + RotaSettings.tripId = "" + RotaSettings.tripShareToken = "" sampler = SmartBeaconSampler() highwayResolver?.reset() @@ -235,15 +235,15 @@ object RtotaTripManager { resumeHandshakePending = false queue?.clear() _state.value = - RtotaTripState( + RotaTripState( active = true, - tripName = RtotaSettings.tripName, + tripName = RotaSettings.tripName, startedMs = startedMs, pendingCreate = true, ) - log("startTrip '${RtotaSettings.tripName}' privacy=${privacy ?: "(default)"}") - // From here every generated CQ goes out as "CQ RTOA ". - RtotaCqSession.apply() + log("startTrip '${RotaSettings.tripName}' privacy=${privacy ?: "(default)"}") + // From here every generated CQ goes out as "CQ ROTA ". + RotaCqSession.apply() startTracking() // The trip notes ride along with the deferred create. pendingNotes = notes @@ -259,19 +259,19 @@ object RtotaTripManager { * the flush loop finishes the job once the phone is back online. */ fun endTrip() { - if (!RtotaSettings.hasActiveTrip) return + if (!RotaSettings.hasActiveTrip) return // Pin the last known position before tracking stops, so the route ends // where the trip ended rather than at the last beacon behind it. lastRawFix?.let { fix -> sampler.anchorForQso(fix)?.let { recordPoint(it) } } log( - "endTrip '${RtotaSettings.tripName}' queued=${queue?.pointCount() ?: 0}pts/" + + "endTrip '${RotaSettings.tripName}' queued=${queue?.pointCount() ?: 0}pts/" + "${queue?.qsoCount() ?: 0}qsos", ) - RtotaSettings.tripPendingComplete = true + RotaSettings.tripPendingComplete = true // Released now, not when the server finally acks the completion: out of // coverage that ack can be hours away, and the operator is off the road // and off the trip the moment they say so. - RtotaCqSession.release() + RotaCqSession.release() stopTracking() requestFlush("end-trip") } @@ -282,19 +282,19 @@ object RtotaTripManager { * logbook remains the way to publish it. */ fun abandonTrip() { - RtotaCqSession.release() + RotaCqSession.release() stopTracking() queue?.clear() - RtotaSettings.clearTrip() + RotaSettings.clearTrip() sampler.reset() lastRawFix = null resumeHandshakePending = false - _state.value = RtotaTripState() + _state.value = RotaTripState() log("abandonTrip — local queue dropped") } private fun defaultTripName(startedMs: Long): String { - val call = RtotaSettings.callsign.ifBlank { GeneralVariables.myCallsign.orEmpty() } + val call = RotaSettings.callsign.ifBlank { GeneralVariables.myCallsign.orEmpty() } val day = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(startedMs)) return listOf(call, day).filter { it.isNotBlank() }.joinToString(" ") } @@ -304,12 +304,12 @@ object RtotaTripManager { // ----------------------------------------------------------------------- /** - * A GPS fix from [RtotaLocationTracker], arriving about once a second. + * A GPS fix from [RotaLocationTracker], arriving about once a second. * [SmartBeaconSampler] decides which ones become route points; the rest still * update [lastRawFix] so a QSO can be placed precisely. */ fun onLocationFix(location: Location) { - if (!RtotaSettings.hasActiveTrip) return + if (!RotaSettings.hasActiveTrip) return val state = stateForLatLon(location.latitude, location.longitude) val candidate = TripPoint( @@ -383,7 +383,7 @@ object RtotaTripManager { @Suppress("TooGenericExceptionCaught") // see the "never throws" note above fun onQsoLogged(record: QSLRecord?) { try { - if (record == null || !RtotaSettings.enabled || !RtotaSettings.hasActiveTrip) return + if (record == null || !RotaSettings.enabled || !RotaSettings.hasActiveTrip) return val here = lastRawFix ?: sampler.lastAccepted // The record was stamped with a position moments ago by the QSO save path // (RoverPosition), which runs whether or not a trip is active. Preferring it @@ -391,7 +391,7 @@ object RtotaTripManager { // ADIF report the identical coordinate — they dedupe into one row server-side, // and two spellings of "where it happened" would be settled by arrival order. val qso = - RtotaQsoMapper.tripQsoFromRecord( + RotaQsoMapper.tripQsoFromRecord( record = record, roverLat = record.myLat ?: here?.latitude, roverLon = record.myLon ?: here?.longitude, @@ -433,7 +433,7 @@ object RtotaTripManager { /** Ask for a flush. Cheap and safe to call from any thread, as often as you like. */ fun requestFlush(reason: String) { - if (!RtotaSettings.enabled || !RtotaSettings.isConfigured) return + if (!RotaSettings.enabled || !RotaSettings.isConfigured) return scope.launch { flush(reason) } } @@ -443,30 +443,30 @@ object RtotaTripManager { */ private suspend fun flush(reason: String) { val q = queue ?: return - if (!RtotaSettings.hasActiveTrip) return - val apiKey = RtotaSettings.apiKey + if (!RotaSettings.hasActiveTrip) return + val apiKey = RotaSettings.apiKey if (apiKey.isBlank()) return flushLock.withLock { - val baseUrl = RtotaSettings.baseUrl + val baseUrl = RotaSettings.baseUrl _state.value = _state.value.copy(uploading = true) // 1. The trip may not exist server-side yet (started out of coverage). - if (RtotaSettings.tripPendingCreate) { + if (RotaSettings.tripPendingCreate) { val created = - RtotaClient.createTrip( + RotaClient.createTrip( baseUrl = baseUrl, apiKey = apiKey, - name = RtotaSettings.tripName, - startTimeMs = RtotaSettings.tripStartedMs, + name = RotaSettings.tripName, + startTimeMs = RotaSettings.tripStartedMs, notes = pendingNotes, - privacy = RtotaSettings.tripPrivacy.takeIf { it.isNotBlank() }, + privacy = RotaSettings.tripPrivacy.takeIf { it.isNotBlank() }, ) created.fold( onSuccess = { handle -> - RtotaSettings.tripId = handle.id - RtotaSettings.tripShareToken = handle.shareToken.orEmpty() - RtotaSettings.tripPendingCreate = false + RotaSettings.tripId = handle.id + RotaSettings.tripShareToken = handle.shareToken.orEmpty() + RotaSettings.tripPendingCreate = false pendingNotes = null _state.value = _state.value.copy( @@ -483,7 +483,7 @@ object RtotaTripManager { ) } - val tripId = RtotaSettings.tripId + val tripId = RotaSettings.tripId if (tripId.isEmpty()) { _state.value = _state.value.copy(uploading = false) return@withLock @@ -497,7 +497,7 @@ object RtotaTripManager { // trip the backoff — the plain re-send below still works, the server // dedupes, and the only cost is bytes. if (resumeHandshakePending) { - RtotaClient.fetchSyncState(baseUrl, apiKey, tripId).fold( + RotaClient.fetchSyncState(baseUrl, apiKey, tripId).fold( onSuccess = { sync -> resumeHandshakePending = false val pruned = q.pruneAcknowledgedQsos(sync.qsoDedupeKeys) @@ -519,7 +519,7 @@ object RtotaTripManager { while (!q.isEmpty()) { val batch = q.peekBatch() if (batch.isEmpty) break - val result = RtotaClient.sendLive(baseUrl, apiKey, tripId, batch) + val result = RotaClient.sendLive(baseUrl, apiKey, tripId, batch) result.fold( onSuccess = { ack -> q.commit(batch) @@ -541,14 +541,14 @@ object RtotaTripManager { } // 4. Finalize once nothing is left to send. - if (RtotaSettings.tripPendingComplete && q.isEmpty()) { - RtotaClient.completeTrip(baseUrl, apiKey, tripId).fold( + if (RotaSettings.tripPendingComplete && q.isEmpty()) { + RotaClient.completeTrip(baseUrl, apiKey, tripId).fold( onSuccess = { log("trip completed id=$tripId") - RtotaSettings.clearTrip() + RotaSettings.clearTrip() sampler.reset() lastRawFix = null - _state.value = RtotaTripState() + _state.value = RotaTripState() }, onFailure = { e -> failed(e, "complete") @@ -573,10 +573,10 @@ object RtotaTripManager { error: Throwable, step: String, ) { - val retryable = isRetryableRtotaFailure(error) + val retryable = isRetryableRotaFailure(error) val message = when (error) { - is RtotaHttpException -> error.serverMessage ?: "HTTP ${error.httpCode}" + is RotaHttpException -> error.serverMessage ?: "HTTP ${error.httpCode}" else -> error.message ?: error.javaClass.simpleName } _state.value = _state.value.copy(uploading = false, lastError = message) @@ -587,7 +587,7 @@ object RtotaTripManager { return } failedAttempts++ - scheduleRetry(rtotaBackoffMs(failedAttempts)) + scheduleRetry(rotaBackoffMs(failedAttempts)) } private fun scheduleRetry(delayMs: Long) { @@ -628,12 +628,12 @@ object RtotaTripManager { private fun startTracking() { val ctx = appContext ?: return - RtotaTripService.start(ctx) + RotaTripService.start(ctx) } private fun stopTracking() { val ctx = appContext ?: return - RtotaTripService.stop(ctx) + RotaTripService.stop(ctx) } /** Refresh the queue-derived counters and push them to the notification. */ @@ -644,7 +644,7 @@ object RtotaTripManager { pendingPoints = q?.pointCount() ?: 0, pendingQsos = q?.qsoCount() ?: 0, ) - appContext?.let { RtotaTripService.updateNotification(it, _state.value) } + appContext?.let { RotaTripService.updateNotification(it, _state.value) } } private fun log(msg: String) { @@ -653,7 +653,7 @@ object RtotaTripManager { val ctx = appContext ?: GeneralVariables.getMainContext() ?: return val dir = ctx.getExternalFilesDir(null) ?: return val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(Date()) - FileWriter(File(dir, "debug.log"), true).use { it.append("$ts Rtota: $msg\n") } + FileWriter(File(dir, "debug.log"), true).use { it.append("$ts Rota: $msg\n") } } catch (_: Exception) { } } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaTripService.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaTripService.kt similarity index 81% rename from ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaTripService.kt rename to ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaTripService.kt index 49fe776a6..cd4baef42 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/RtotaTripService.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaTripService.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import android.app.Notification import android.app.NotificationChannel @@ -24,15 +24,15 @@ import java.util.Locale * foreground service that declares the `location` type, so trip tracking cannot * live in the activity: the phone is usually in a cradle with the screen off (or * showing a map app) while the rover drives. The service exists purely to hold - * that permission window open, own the [RtotaLocationTracker] subscription, and + * that permission window open, own the [RotaLocationTracker] subscription, and * show the ongoing notification the OS requires — the queueing and uploading all - * stay in [RtotaTripManager], which outlives it. + * stay in [RotaTripManager], which outlives it. * * The notification doubles as the trip's dashboard: miles, contacts, and how much * is still waiting to upload, plus a one-tap End Trip. */ -class RtotaTripService : Service() { - private var tracker: RtotaLocationTracker? = null +class RotaTripService : Service() { + private var tracker: RotaLocationTracker? = null override fun onBind(intent: Intent?): IBinder? = null @@ -51,12 +51,12 @@ class RtotaTripService : Service() { startId: Int, ): Int { if (intent?.action == ACTION_END_TRIP) { - GeneralVariables.fileLog("RtotaTripService: End Trip tapped") - RtotaTripManager.endTrip() + GeneralVariables.fileLog("RotaTripService: End Trip tapped") + RotaTripManager.endTrip() return START_NOT_STICKY } - val notification = buildNotification(this, RtotaTripManager.state.value) + val notification = buildNotification(this, RotaTripManager.state.value) try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { startForeground(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) @@ -66,18 +66,18 @@ class RtotaTripService : Service() { } catch (e: Exception) { // Location permission missing, or a background-start restriction on // Android 12+. Trip mode degrades to "QSOs only" rather than crashing. - GeneralVariables.fileLog("RtotaTripService start failed: $e") + GeneralVariables.fileLog("RotaTripService start failed: $e") stopSelf() return START_NOT_STICKY } - val t = tracker ?: RtotaLocationTracker(this).also { tracker = it } + val t = tracker ?: RotaLocationTracker(this).also { tracker = it } if (!t.start()) { Log.d(TAG, "tracker did not start (no permission / no provider)") } running = true // STICKY: if the OS reclaims the process mid-trip, restart tracking as - // soon as memory allows — the trip itself is restored from RtotaSettings. + // soon as memory allows — the trip itself is restored from RotaSettings. return START_STICKY } @@ -101,20 +101,20 @@ class RtotaTripService : Service() { val channel = NotificationChannel( CHANNEL_ID, - getString(R.string.rtota_service_channel_name), + getString(R.string.rota_service_channel_name), NotificationManager.IMPORTANCE_LOW, ).apply { - description = getString(R.string.rtota_service_channel_desc) + description = getString(R.string.rota_service_channel_desc) setShowBadge(false) } nm.createNotificationChannel(channel) } companion object { - private const val TAG = "RtotaTripService" - private const val CHANNEL_ID = "rtota_trip" + private const val TAG = "RotaTripService" + private const val CHANNEL_ID = "rota_trip" private const val NOTIF_ID = 0x52544F54 // "RTOT" - const val ACTION_END_TRIP = "radio.ks3ckc.ft8af.rtota.END_TRIP" + const val ACTION_END_TRIP = "radio.ks3ckc.ft8af.rota.END_TRIP" @Volatile private var running = false @@ -124,16 +124,16 @@ class RtotaTripService : Service() { try { ContextCompat.startForegroundService( context, - Intent(context, RtotaTripService::class.java), + Intent(context, RotaTripService::class.java), ) } catch (e: Exception) { - GeneralVariables.fileLog("RtotaTripService.start failed: $e") + GeneralVariables.fileLog("RotaTripService.start failed: $e") } } fun stop(context: Context) { try { - context.stopService(Intent(context, RtotaTripService::class.java)) + context.stopService(Intent(context, RotaTripService::class.java)) } catch (_: Exception) { } } @@ -141,7 +141,7 @@ class RtotaTripService : Service() { /** Refresh the ongoing notification's counters. No-op when not running. */ fun updateNotification( context: Context, - state: RtotaTripState, + state: RotaTripState, ) { if (!running) return try { @@ -158,7 +158,7 @@ class RtotaTripService : Service() { * clause only appears when there is a backlog, so on a good connection the * line stays quiet. */ - internal fun notificationText(state: RtotaTripState): String { + internal fun notificationText(state: RotaTripState): String { val parts = mutableListOf( String.format(Locale.US, "%.1f mi", state.miles), @@ -176,7 +176,7 @@ class RtotaTripService : Service() { private fun buildNotification( context: Context, - state: RtotaTripState, + state: RotaTripState, ): Notification { var piFlags = PendingIntent.FLAG_UPDATE_CURRENT if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -190,10 +190,10 @@ class RtotaTripService : Service() { PendingIntent.getService( context, 2, - Intent(context, RtotaTripService::class.java).setAction(ACTION_END_TRIP), + Intent(context, RotaTripService::class.java).setAction(ACTION_END_TRIP), piFlags, ) - val title = state.tripName.ifBlank { context.getString(R.string.rtota_service_title) } + val title = state.tripName.ifBlank { context.getString(R.string.rota_service_title) } return NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle(title) @@ -204,7 +204,7 @@ class RtotaTripService : Service() { .setContentIntent(openPi) .addAction( R.drawable.ic_baseline_close_32, - context.getString(R.string.rtota_action_end_trip), + context.getString(R.string.rota_action_end_trip), endPi, ) .build() diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconSampler.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/SmartBeaconSampler.kt similarity index 97% rename from ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconSampler.kt rename to ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/SmartBeaconSampler.kt index 8a78ad558..a0990257a 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconSampler.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/SmartBeaconSampler.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import kotlin.math.asin import kotlin.math.cos @@ -63,7 +63,7 @@ internal const val METERS_PER_MILE = 1609.344 * - Rates are far tighter than APRS defaults (which exist to protect a shared * 2 m channel). We are drawing a map line, so the trail can be dense. * - A true standstill emits *nothing* — see [stationaryRadiusM]. APRS keeps - * beaconing so you stay visible; RTOTA derives overnight stops from ≥4 h gaps + * beaconing so you stay visible; ROTA derives overnight stops from ≥4 h gaps * in the trail, so silence while parked is the signal, not a failure. */ data class SmartBeaconProfile( @@ -91,7 +91,7 @@ data class SmartBeaconProfile( ) { companion object { /** - * The tuning RTOTA ships. It is a road-trip service — the rover is in a + * The tuning ROTA ships. It is a road-trip service — the rover is in a * vehicle — so there is one profile and no picker: numbers tuned for * highway speeds, giving ~0.6 mi between points at 70 mph. * @@ -327,7 +327,7 @@ class SmartBeaconSampler( /** * Fold the "did we just become parked" bookkeeping into the offer path. - * Called by [RtotaTripManager] on every fix, including the dropped ones, so + * Called by [RotaTripManager] on every fix, including the dropped ones, so * the parked flag reflects the *raw* stream rather than only kept points. */ fun noteStationary(point: TripPoint) { diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rtota/RoadTripScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rota/RoadTripScreen.kt similarity index 77% rename from ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rtota/RoadTripScreen.kt rename to ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rota/RoadTripScreen.kt index fb25c4a87..a042e760e 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rtota/RoadTripScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rota/RoadTripScreen.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.ui.rtota +package radio.ks3ckc.ft8af.ui.rota import android.Manifest import android.app.Activity @@ -41,16 +41,16 @@ import com.k1af.ft8af.GeneralVariables import com.k1af.ft8af.R import kotlinx.coroutines.launch import radio.ks3ckc.ft8af.location.hasLocationPermission -import radio.ks3ckc.ft8af.rtota.BeaconReason -import radio.ks3ckc.ft8af.rtota.RtotaActivation -import radio.ks3ckc.ft8af.rtota.RtotaHttpException -import radio.ks3ckc.ft8af.rtota.activationMatchesNow -import radio.ks3ckc.ft8af.rtota.RTOTA_CQ_MODIFIER -import radio.ks3ckc.ft8af.rtota.RtotaClient -import radio.ks3ckc.ft8af.rtota.RtotaSettings -import radio.ks3ckc.ft8af.rtota.RtotaTripManager -import radio.ks3ckc.ft8af.rtota.RtotaTripState -import radio.ks3ckc.ft8af.rtota.SmartBeaconProfile +import radio.ks3ckc.ft8af.rota.BeaconReason +import radio.ks3ckc.ft8af.rota.RotaActivation +import radio.ks3ckc.ft8af.rota.RotaHttpException +import radio.ks3ckc.ft8af.rota.activationMatchesNow +import radio.ks3ckc.ft8af.rota.ROTA_CQ_MODIFIER +import radio.ks3ckc.ft8af.rota.RotaClient +import radio.ks3ckc.ft8af.rota.RotaSettings +import radio.ks3ckc.ft8af.rota.RotaTripManager +import radio.ks3ckc.ft8af.rota.RotaTripState +import radio.ks3ckc.ft8af.rota.SmartBeaconProfile import radio.ks3ckc.ft8af.theme.* import radio.ks3ckc.ft8af.ui.components.GlassCard import radio.ks3ckc.ft8af.ui.components.SettingsRow @@ -61,25 +61,25 @@ import java.util.Date import java.util.Locale /** - * RTOTA trip mode screen: register with rtota.app, start and end a road trip, + * ROTA trip mode screen: register with roadsontheair.com, start and end a road trip, * watch what has reached the server, and announce a trip before it starts. * - * The screen is a thin view over [RtotaTripManager] — it never touches GPS or + * The screen is a thin view over [RotaTripManager] — it never touches GPS or * the queue itself, so closing it (or the whole app) has no effect on a trip in * progress. Everything long-running is reported through - * [RtotaTripManager.state] rather than owned here. + * [RotaTripManager.state] rather than owned here. */ @Composable fun RoadTripScreen(onBack: () -> Unit) { val context = LocalContext.current val scope = rememberCoroutineScope() - val state by RtotaTripManager.state.collectAsState() + val state by RotaTripManager.state.collectAsState() - var enabled by remember { mutableStateOf(RtotaSettings.enabled) } - var callsign by remember { mutableStateOf(RtotaSettings.callsign) } - var baseUrl by remember { mutableStateOf(RtotaSettings.baseUrl) } - var apiKey by remember { mutableStateOf(RtotaSettings.apiKey) } - var privacy by remember { mutableStateOf(RtotaSettings.defaultPrivacy) } + var enabled by remember { mutableStateOf(RotaSettings.enabled) } + var callsign by remember { mutableStateOf(RotaSettings.callsign) } + var baseUrl by remember { mutableStateOf(RotaSettings.baseUrl) } + var apiKey by remember { mutableStateOf(RotaSettings.apiKey) } + var privacy by remember { mutableStateOf(RotaSettings.defaultPrivacy) } var tripName by remember { mutableStateOf("") } var busy by remember { mutableStateOf(false) } @@ -90,7 +90,7 @@ fun RoadTripScreen(onBack: () -> Unit) { var showKeyDialog by remember { mutableStateOf(false) } var showTripNameDialog by remember { mutableStateOf(false) } var showPlanPicker by remember { mutableStateOf(false) } - var plans by remember { mutableStateOf>(emptyList()) } + var plans by remember { mutableStateOf>(emptyList()) } var plansLoading by remember { mutableStateOf(false) } var plansError by remember { mutableStateOf(null) } var showAnnounceDialog by remember { mutableStateOf(false) } @@ -98,7 +98,7 @@ fun RoadTripScreen(onBack: () -> Unit) { // Trip tracking needs location; ask here rather than at the moment the user // taps Start, so a denied prompt doesn't silently produce a trip with no route. fun ensureLocationPermission(): Boolean { - // Fine *or* coarse, matching what RtotaLocationTracker actually needs. + // Fine *or* coarse, matching what RotaLocationTracker actually needs. // Gating on fine alone rejected an approximate-only grant — an ordinary // choice in the Android 12+ permission dialog — and re-prompted for a // permission the user had already given. @@ -119,44 +119,44 @@ fun RoadTripScreen(onBack: () -> Unit) { } if (showCallsignDialog) { - RtotaTextDialog( - title = stringResource(R.string.rtota_callsign), + RotaTextDialog( + title = stringResource(R.string.rota_callsign), initial = callsign, onDismiss = { showCallsignDialog = false }, onSave = { callsign = it.trim().uppercase(Locale.US) - RtotaSettings.callsign = callsign + RotaSettings.callsign = callsign showCallsignDialog = false }, ) } if (showServerDialog) { - RtotaTextDialog( - title = stringResource(R.string.rtota_server), + RotaTextDialog( + title = stringResource(R.string.rota_server), initial = baseUrl, onDismiss = { showServerDialog = false }, onSave = { - RtotaSettings.baseUrl = it - baseUrl = RtotaSettings.baseUrl + RotaSettings.baseUrl = it + baseUrl = RotaSettings.baseUrl showServerDialog = false }, ) } if (showKeyDialog) { - RtotaTextDialog( - title = stringResource(R.string.rtota_api_key), + RotaTextDialog( + title = stringResource(R.string.rota_api_key), initial = apiKey, - hint = stringResource(R.string.rtota_api_key_hint), + hint = stringResource(R.string.rota_api_key_hint), onDismiss = { showKeyDialog = false }, onSave = { apiKey = it.trim() - RtotaSettings.apiKey = apiKey + RotaSettings.apiKey = apiKey showKeyDialog = false // A key that arrives after a trip started offline unblocks the // whole backlog. - RtotaTripManager.requestFlush("api-key-set") + RotaTripManager.requestFlush("api-key-set") }, ) } @@ -180,10 +180,10 @@ fun RoadTripScreen(onBack: () -> Unit) { } if (showTripNameDialog) { - RtotaTextDialog( - title = stringResource(R.string.rtota_trip_name), + RotaTextDialog( + title = stringResource(R.string.rota_trip_name), initial = tripName, - hint = stringResource(R.string.rtota_trip_name_hint), + hint = stringResource(R.string.rota_trip_name_hint), onDismiss = { showTripNameDialog = false }, onSave = { tripName = it.trim() @@ -201,9 +201,9 @@ fun RoadTripScreen(onBack: () -> Unit) { scope.launch { val start = System.currentTimeMillis() + hoursFromNow * 3_600_000L val result = - RtotaClient.createActivation( - baseUrl = RtotaSettings.baseUrl, - apiKey = RtotaSettings.apiKey, + RotaClient.createActivation( + baseUrl = RotaSettings.baseUrl, + apiKey = RotaSettings.apiKey, title = title, startTimeMs = start, privacy = privacy.takeIf { it.isNotBlank() }, @@ -211,7 +211,7 @@ fun RoadTripScreen(onBack: () -> Unit) { busy = false message = result.fold( - onSuccess = { context.getString(R.string.rtota_announced) }, + onSuccess = { context.getString(R.string.rota_announced) }, onFailure = { it.message ?: it.javaClass.simpleName }, ) } @@ -219,25 +219,25 @@ fun RoadTripScreen(onBack: () -> Unit) { ) } - SettingsDetailScaffold(title = stringResource(R.string.rtota_title), onBack = onBack) { + SettingsDetailScaffold(title = stringResource(R.string.rota_title), onBack = onBack) { // ===================================================================== // ACCOUNT // ===================================================================== - SettingsSection(title = stringResource(R.string.rtota_section_account)) { + SettingsSection(title = stringResource(R.string.rota_section_account)) { GlassCard(modifier = Modifier.fillMaxWidth()) { Column { SettingsRow( - label = stringResource(R.string.rtota_enable), - description = stringResource(R.string.rtota_enable_desc), + label = stringResource(R.string.rota_enable), + description = stringResource(R.string.rota_enable_desc), toggle = enabled, onToggleChange = { checked -> enabled = checked - RtotaSettings.enabled = checked - if (checked) RtotaTripManager.init(context) + RotaSettings.enabled = checked + if (checked) RotaTripManager.init(context) }, ) SettingsRow( - label = stringResource(R.string.rtota_callsign), + label = stringResource(R.string.rota_callsign), value = callsign.ifBlank { GeneralVariables.myCallsign.orEmpty() } .ifBlank { "--" }, @@ -245,13 +245,13 @@ fun RoadTripScreen(onBack: () -> Unit) { onClick = { showCallsignDialog = true }, ) SettingsRow( - label = stringResource(R.string.rtota_server), + label = stringResource(R.string.rota_server), value = baseUrl.removePrefix("https://"), showChevron = true, onClick = { showServerDialog = true }, ) SettingsRow( - label = stringResource(R.string.rtota_api_key), + label = stringResource(R.string.rota_api_key), value = if (apiKey.isBlank()) { stringResource(R.string.common_not_configured) @@ -265,11 +265,11 @@ fun RoadTripScreen(onBack: () -> Unit) { SettingsRow( label = if (busy) { - stringResource(R.string.rtota_registering) + stringResource(R.string.rota_registering) } else { - stringResource(R.string.rtota_register) + stringResource(R.string.rota_register) }, - description = stringResource(R.string.rtota_register_desc), + description = stringResource(R.string.rota_register_desc), showChevron = true, onClick = { if (busy) return@SettingsRow @@ -277,8 +277,8 @@ fun RoadTripScreen(onBack: () -> Unit) { message = null scope.launch { val result = - RtotaClient.registerOperator( - baseUrl = RtotaSettings.baseUrl, + RotaClient.registerOperator( + baseUrl = RotaSettings.baseUrl, callsign = callsign.ifBlank { GeneralVariables.myCallsign.orEmpty() @@ -289,8 +289,8 @@ fun RoadTripScreen(onBack: () -> Unit) { result.fold( onSuccess = { key -> apiKey = key - RtotaSettings.apiKey = key - message = context.getString(R.string.rtota_registered) + RotaSettings.apiKey = key + message = context.getString(R.string.rota_registered) }, onFailure = { message = it.message ?: it.javaClass.simpleName @@ -308,25 +308,25 @@ fun RoadTripScreen(onBack: () -> Unit) { // ===================================================================== // TRIP // ===================================================================== - SettingsSection(title = stringResource(R.string.rtota_section_trip)) { + SettingsSection(title = stringResource(R.string.rota_section_trip)) { GlassCard(modifier = Modifier.fillMaxWidth()) { if (state.active) { ActiveTripCard( state = state, - onEnd = { RtotaTripManager.endTrip() }, - onAbandon = { RtotaTripManager.abandonTrip() }, + onEnd = { RotaTripManager.endTrip() }, + onAbandon = { RotaTripManager.abandonTrip() }, ) } else { Column { SettingsRow( - label = stringResource(R.string.rtota_trip_name), + label = stringResource(R.string.rota_trip_name), value = tripName.ifBlank { "--" }, showChevron = true, onClick = { // Straight to the free-text box when there is no key — // the plans live behind it, and an empty picker with an // auth error in it explains nothing. - if (RtotaSettings.apiKey.isBlank()) { + if (RotaSettings.apiKey.isBlank()) { showTripNameDialog = true return@SettingsRow } @@ -334,14 +334,14 @@ fun RoadTripScreen(onBack: () -> Unit) { plansLoading = true plansError = null scope.launch { - RtotaClient.fetchMyActivations( - RtotaSettings.baseUrl, - RtotaSettings.apiKey, + RotaClient.fetchMyActivations( + RotaSettings.baseUrl, + RotaSettings.apiKey, ).fold( onSuccess = { plans = it }, onFailure = { e -> plansError = - (e as? RtotaHttpException)?.serverMessage + (e as? RotaHttpException)?.serverMessage ?: e.message ?: e.javaClass.simpleName }, @@ -351,34 +351,34 @@ fun RoadTripScreen(onBack: () -> Unit) { }, ) SettingsRow( - label = stringResource(R.string.rtota_privacy), + label = stringResource(R.string.rota_privacy), value = privacy.ifBlank { - stringResource(R.string.rtota_privacy_default) + stringResource(R.string.rota_privacy_default) }, onClick = { // Cycle: account default -> public -> delayed -> // followers -> private -> back to default. privacy = nextPrivacy(privacy) - RtotaSettings.defaultPrivacy = privacy + RotaSettings.defaultPrivacy = privacy }, ) SettingsRow( - label = stringResource(R.string.rtota_start_trip), + label = stringResource(R.string.rota_start_trip), showChevron = true, onClick = { when { - RtotaSettings.apiKey.isBlank() -> - message = context.getString(R.string.rtota_need_key) + RotaSettings.apiKey.isBlank() -> + message = context.getString(R.string.rota_need_key) !ensureLocationPermission() -> - message = context.getString(R.string.rtota_need_location) + message = context.getString(R.string.rota_need_location) else -> { - if (!RtotaSettings.enabled) { - RtotaSettings.enabled = true + if (!RotaSettings.enabled) { + RotaSettings.enabled = true enabled = true } - RtotaTripManager.init(context) - RtotaTripManager.startTrip( + RotaTripManager.init(context) + RotaTripManager.startTrip( name = tripName, privacy = privacy.takeIf { it.isNotBlank() }, ) @@ -395,14 +395,14 @@ fun RoadTripScreen(onBack: () -> Unit) { // ===================================================================== // TRACKING (SmartBeaconing) // ===================================================================== - SettingsSection(title = stringResource(R.string.rtota_section_tracking)) { + SettingsSection(title = stringResource(R.string.rota_section_tracking)) { GlassCard(modifier = Modifier.fillMaxWidth()) { // Read-only: the sampling is tuned for a vehicle and there is // nothing here worth a knob. Stating what it does still matters — // a trail that goes quiet at a fuel stop looks broken otherwise. Column(modifier = Modifier.padding(16.dp)) { Text( - text = stringResource(R.string.rtota_beacon_heading), + text = stringResource(R.string.rota_beacon_heading), color = TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, @@ -420,15 +420,15 @@ fun RoadTripScreen(onBack: () -> Unit) { // ===================================================================== // ANNOUNCE // ===================================================================== - SettingsSection(title = stringResource(R.string.rtota_section_activation)) { + SettingsSection(title = stringResource(R.string.rota_section_activation)) { GlassCard(modifier = Modifier.fillMaxWidth()) { SettingsRow( - label = stringResource(R.string.rtota_announce), - description = stringResource(R.string.rtota_announce_desc), + label = stringResource(R.string.rota_announce), + description = stringResource(R.string.rota_announce_desc), showChevron = true, onClick = { - if (RtotaSettings.apiKey.isBlank()) { - message = context.getString(R.string.rtota_need_key) + if (RotaSettings.apiKey.isBlank()) { + message = context.getString(R.string.rota_need_key) } else { showAnnounceDialog = true } @@ -444,72 +444,71 @@ private const val REQUEST_LOCATION = 4201 /** Live trip readout: what has been recorded, what is still waiting. */ @Composable private fun ActiveTripCard( - state: RtotaTripState, + state: RotaTripState, onEnd: () -> Unit, onAbandon: () -> Unit, ) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - text = state.tripName.ifBlank { stringResource(R.string.rtota_service_title) }, + text = state.tripName.ifBlank { stringResource(R.string.rota_service_title) }, color = TextPrimary, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, ) StatLine( - stringResource(R.string.rtota_stat_miles), + stringResource(R.string.rota_stat_miles), String.format(Locale.US, "%.1f", state.miles), ) StatLine( - stringResource(R.string.rtota_stat_qsos), + stringResource(R.string.rota_stat_qsos), "${state.sentQsos}", ) StatLine( - stringResource(R.string.rtota_stat_pending), + stringResource(R.string.rota_stat_pending), "${state.pendingPoints + state.pendingQsos}", ) StatLine( - stringResource(R.string.rtota_stat_last_upload), + stringResource(R.string.rota_stat_last_upload), if (state.lastUploadMs > 0) { SimpleDateFormat("HH:mm:ss", Locale.US).format(Date(state.lastUploadMs)) } else { - stringResource(R.string.rtota_stat_never) + stringResource(R.string.rota_stat_never) }, ) // Why the last route point was kept — makes SmartBeaconing legible from // the passenger seat ("corner" on a curve, "parked" at a fuel stop). StatLine( - stringResource(R.string.rtota_stat_last_point), + stringResource(R.string.rota_stat_last_point), when { - state.parked -> stringResource(R.string.rtota_beacon_parked) + state.parked -> stringResource(R.string.rota_beacon_parked) else -> stringResource(beaconReasonLabelRes(state.lastBeaconReason)) }, ) - // Spelled out because it is not the spelling anyone expects: an FT8 CQ - // modifier is at most four letters, so the trip calls RTOA, not RTOTA. + // Shown verbatim so the operator can see exactly what is going out. StatLine( - stringResource(R.string.rtota_stat_cq), - "CQ $RTOTA_CQ_MODIFIER", + stringResource(R.string.rota_stat_cq), + "CQ $ROTA_CQ_MODIFIER", ) if (state.pendingCreate) { Text( - text = stringResource(R.string.rtota_pending_create), + text = stringResource(R.string.rota_pending_create), color = TextMuted, fontSize = 12.sp, ) } state.lastError?.let { Text( - text = stringResource(R.string.rtota_error, it), + text = stringResource(R.string.rota_error, it), color = StatusBad, fontSize = 12.sp, ) } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { TextButton(onClick = onEnd) { - Text(stringResource(R.string.rtota_end_trip), color = Accent) + Text(stringResource(R.string.rota_end_trip), color = Accent) } TextButton(onClick = onAbandon) { - Text(stringResource(R.string.rtota_abandon), color = TextMuted) + Text(stringResource(R.string.rota_abandon), color = TextMuted) } } } @@ -547,7 +546,7 @@ private fun NoticeText(text: String) { } /** - * Pick the trip's name from the plans already saved on rtota.app, or type one. + * Pick the trip's name from the plans already saved on roadsontheair.com, or type one. * * The plans are *scheduled activations* — what the site's plan wizard writes — * because a trip only exists once someone drives it. Choosing one here does not @@ -560,7 +559,7 @@ private fun NoticeText(text: String) { */ @Composable private fun TripPlanPickerDialog( - plans: List, + plans: List, loading: Boolean, error: String?, nowMs: Long, @@ -579,7 +578,7 @@ private fun TripPlanPickerDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = stringResource(R.string.rtota_pick_plan), + text = stringResource(R.string.rota_pick_plan), color = TextPrimary, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, @@ -588,22 +587,22 @@ private fun TripPlanPickerDialog( when { loading -> Text( - text = stringResource(R.string.rtota_pick_plan_loading), + text = stringResource(R.string.rota_pick_plan_loading), color = TextMuted, fontSize = 13.sp, ) error != null -> - // Not rtota_error ("Last error: …") — that phrasing belongs to the + // Not rota_error ("Last error: …") — that phrasing belongs to the // trip's own upload failures, and reusing it here would report a // failed plan fetch as though the running trip were in trouble. Text( - text = stringResource(R.string.rtota_pick_plan_error, error), + text = stringResource(R.string.rota_pick_plan_error, error), color = StatusBad, fontSize = 13.sp, ) plans.isEmpty() -> Text( - text = stringResource(R.string.rtota_pick_plan_empty), + text = stringResource(R.string.rota_pick_plan_empty), color = TextMuted, fontSize = 13.sp, ) @@ -635,10 +634,10 @@ private fun TripPlanPickerDialog( Text( text = if (matches) { - stringResource(R.string.rtota_plan_matches_now) + stringResource(R.string.rota_plan_matches_now) } else { stringResource( - R.string.rtota_plan_starts, + R.string.rota_plan_starts, formatPlanStart(plan.startTimeMs), ) }, @@ -658,7 +657,7 @@ private fun TripPlanPickerDialog( Text(stringResource(R.string.action_cancel), color = TextMuted) } TextButton(onClick = onTypeName) { - Text(stringResource(R.string.rtota_pick_plan_custom), color = Accent) + Text(stringResource(R.string.rota_pick_plan_custom), color = Accent) } } } @@ -671,7 +670,7 @@ internal fun formatPlanStart(startMs: Long): String = /** Single-field dialog shared by the callsign / server / key / trip-name rows. */ @Composable -private fun RtotaTextDialog( +private fun RotaTextDialog( title: String, initial: String, hint: String? = null, @@ -695,7 +694,7 @@ private fun RtotaTextDialog( onValueChange = { input = it }, placeholder = hint?.let { { Text(it, color = TextFaint) } }, singleLine = true, - colors = rtotaFieldColors(), + colors = rotaFieldColors(), textStyle = TextStyle(fontSize = 14.sp), modifier = Modifier.fillMaxWidth(), ) @@ -733,7 +732,7 @@ private fun AnnounceActivationDialog( verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( - text = stringResource(R.string.rtota_announce), + text = stringResource(R.string.rota_announce), color = TextPrimary, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, @@ -741,19 +740,19 @@ private fun AnnounceActivationDialog( OutlinedTextField( value = title, onValueChange = { title = it }, - label = { Text(stringResource(R.string.rtota_announce_title_field)) }, + label = { Text(stringResource(R.string.rota_announce_title_field)) }, singleLine = true, - colors = rtotaFieldColors(), + colors = rotaFieldColors(), textStyle = TextStyle(fontSize = 14.sp), modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( value = hours, onValueChange = { hours = it.filter { c -> c.isDigit() }.take(4) }, - label = { Text(stringResource(R.string.rtota_announce_hours)) }, + label = { Text(stringResource(R.string.rota_announce_hours)) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - colors = rtotaFieldColors(), + colors = rotaFieldColors(), textStyle = TextStyle(fontSize = 14.sp), modifier = Modifier.fillMaxWidth(), ) @@ -768,7 +767,7 @@ private fun AnnounceActivationDialog( } }, ) { - Text(stringResource(R.string.rtota_announce_send), color = Accent) + Text(stringResource(R.string.rota_announce_send), color = Accent) } } } @@ -776,7 +775,7 @@ private fun AnnounceActivationDialog( } @Composable -private fun rtotaFieldColors() = +private fun rotaFieldColors() = OutlinedTextFieldDefaults.colors( focusedTextColor = TextPrimary, unfocusedTextColor = TextPrimary, @@ -794,12 +793,22 @@ private fun rtotaFieldColors() = /** * Show enough of the key to recognise it without printing a working credential * on a screen that may be mirrored to a car display. + * + * The head is cut at the prefix separator rather than a fixed width, because the + * server mints `rota_…` now and `rtota_…` before the rename — the two differ by a + * character, and a hardcoded width would expose a character of the secret itself + * on whichever one it was not sized for. */ -internal fun maskKey(key: String): String = if (key.length <= 8) "••••" else key.take(6) + "…" + key.takeLast(4) +internal fun maskKey(key: String): String { + if (key.length <= 8) return "••••" + val afterPrefix = key.indexOf('_') + 1 + val head = if (afterPrefix in 1..8) key.take(afterPrefix) else key.take(6) + return head + "…" + key.takeLast(4) +} /** Privacy cycle for the tap-to-change row; "" means "let the account decide". */ internal fun nextPrivacy(current: String): String { - val order = listOf("") + RtotaSettings.PRIVACY_LEVELS + val order = listOf("") + RotaSettings.PRIVACY_LEVELS val idx = order.indexOf(current).takeIf { it >= 0 } ?: 0 return order[(idx + 1) % order.size] } @@ -807,12 +816,12 @@ internal fun nextPrivacy(current: String): String { @StringRes internal fun beaconReasonLabelRes(reason: BeaconReason?): Int = when (reason) { - BeaconReason.FIRST -> R.string.rtota_beacon_first - BeaconReason.RATE -> R.string.rtota_beacon_rate - BeaconReason.CORNER -> R.string.rtota_beacon_corner - BeaconReason.RESUME -> R.string.rtota_beacon_resume - BeaconReason.QSO -> R.string.rtota_beacon_qso - null -> R.string.rtota_beacon_waiting + BeaconReason.FIRST -> R.string.rota_beacon_first + BeaconReason.RATE -> R.string.rota_beacon_rate + BeaconReason.CORNER -> R.string.rota_beacon_corner + BeaconReason.RESUME -> R.string.rota_beacon_resume + BeaconReason.QSO -> R.string.rota_beacon_qso + null -> R.string.rota_beacon_waiting } /** diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt index 0243f99ea..4a78839e1 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt @@ -55,7 +55,7 @@ import com.k1af.ft8af.R import com.k1af.ft8af.ft8signal.FT8Package import com.k1af.ft8af.location.GridLocationUpdater import radio.ks3ckc.ft8af.theme.* -import radio.ks3ckc.ft8af.ui.rtota.RoadTripScreen +import radio.ks3ckc.ft8af.ui.rota.RoadTripScreen import radio.ks3ckc.ft8af.ui.components.GlassCard import radio.ks3ckc.ft8af.ui.components.SettingsRow import radio.ks3ckc.ft8af.ui.components.TopBar @@ -328,8 +328,8 @@ private fun SettingsLanding( ) SectionDivider() SettingsRow( - label = stringResource(R.string.rtota_title), - description = stringResource(R.string.rtota_settings_desc), + label = stringResource(R.string.rota_title), + description = stringResource(R.string.rota_settings_desc), showChevron = true, onClick = { onOpenCategory(SettingsCategory.ROAD_TRIP) }, ) diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 651d8e2db..d5966271f 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -879,67 +879,67 @@ No decodes yet POTA %1$s · %2$d QSOs - - Road Trips (RTOTA) - Stream your route and contacts to rtota.app while you drive - ACCOUNT - Trip mode - Record GPS breadcrumbs and push logged QSOs to rtota.app - Rover callsign - Server - API key - Register this callsign - Creates the operator on the server and saves the API key here - Registering… - Registered — API key saved - Paste the key from your rtota.app dashboard - TRIP - Trip name - e.g. Route 66 westbound - Privacy - Account default - Start trip - End trip - Finishing up — uploading the rest… - Discard local trip - Stops tracking and drops anything not yet uploaded - Miles - Contacts sent - Waiting to upload - Last upload - never - Started offline — the trip is created as soon as you have signal - Last error: %1$s - Share link - TRACKING - SmartBeaconing - Last point - Use a saved plan - Loading your plans… - No plans saved on rtota.app yet. Plan one on the site, or type a name. - Type a name - Couldn\'t load your plans: %1$s - Starting now uses this plan\'s privacy settings - Starts %1$s — outside the window, so its privacy won\'t apply - Calling - trip start - interval - corner - moving again - contact - waiting for GPS - parked — not recording - ANNOUNCE - Announce a planned trip - Tell followers when you are heading out - Title - Starting in (hours) - Announce - Announcement posted - Set your API key first - Location permission is required for trip tracking - Road trip tracking - Records your route while a trip is running - Road trip in progress - End trip + + Roads On The Air (ROTA) + Stream your route and contacts to roadsontheair.com while you drive + ACCOUNT + Trip mode + Record GPS breadcrumbs and push logged QSOs to roadsontheair.com + Rover callsign + Server + API key + Register this callsign + Creates the operator on the server and saves the API key here + Registering… + Registered — API key saved + Paste the key from your roadsontheair.com dashboard + TRIP + Trip name + e.g. Route 66 westbound + Privacy + Account default + Start trip + End trip + Finishing up — uploading the rest… + Discard local trip + Stops tracking and drops anything not yet uploaded + Miles + Contacts sent + Waiting to upload + Last upload + never + Started offline — the trip is created as soon as you have signal + Last error: %1$s + Share link + TRACKING + SmartBeaconing + Last point + Use a saved plan + Loading your plans… + No plans saved on roadsontheair.com yet. Plan one on the site, or type a name. + Type a name + Couldn\'t load your plans: %1$s + Starting now uses this plan\'s privacy settings + Starts %1$s — outside the window, so its privacy won\'t apply + Calling + trip start + interval + corner + moving again + contact + waiting for GPS + parked — not recording + ANNOUNCE + Announce a planned trip + Tell followers when you are heading out + Title + Starting in (hours) + Announce + Announcement posted + Set your API key first + Location permission is required for trip tracking + Trip tracking + Records your route while a trip is running + ROTA trip in progress + End trip diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifLocationTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifLocationTest.java index 1a84e29de..1a81337c0 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifLocationTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifLocationTest.java @@ -113,15 +113,15 @@ public void record_emitsBothFieldPairsWhenPositionIsKnown() { .build(); assertThat(adif).contains("N039 44.352"); assertThat(adif).contains("W104 59.418"); - assertThat(adif).contains("39.739200"); - assertThat(adif).contains("-104.990300"); + assertThat(adif).contains("39.739200"); + assertThat(adif).contains("-104.990300"); } @Test public void record_emitsNoPositionFieldsWhenUnknown() { String adif = new AdifRecord().call("K1ABC").build(); assertThat(adif).doesNotContain("MY_LAT"); - assertThat(adif).doesNotContain("APP_RTOTA_LAT"); + assertThat(adif).doesNotContain("APP_ROTA_LAT"); } @Test diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/log/QSLRecordTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/log/QSLRecordTest.java index d75ef9629..eb98c4e7d 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/log/QSLRecordTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/log/QSLRecordTest.java @@ -196,6 +196,71 @@ public void mapConstructor_ft8ModeUnaffectedBySubmodeResolution() { assertThat(r.getMode()).isEqualTo("FT8"); } + @Test + public void mapConstructor_readsRoverPositionFromTheCurrentAppFields() { + HashMap map = new HashMap<>(); + map.put("CALL", "W1AW"); + map.put("COMMENT", "imported"); + map.put("APP_ROTA_LAT", "39.739200"); + map.put("APP_ROTA_LON", "-104.990300"); + + QSLRecord r = new QSLRecord(map); + + assertThat(r.getMyLat()).isWithin(1e-9).of(39.7392); + assertThat(r.getMyLon()).isWithin(1e-9).of(-104.9903); + } + + @Test + public void mapConstructor_stillReadsThePreRenameAppFields() { + // ADIF exported before the Roads On The Air rename — and every install + // still running an older build — spells these APP_RTOTA_. Dropping them + // would silently downgrade an archived log to the rounded MY_LAT/MY_LON + // pair, losing about 1.8 m on every rover fix. + HashMap map = new HashMap<>(); + map.put("CALL", "W1AW"); + map.put("COMMENT", "imported"); + map.put("APP_RTOTA_LAT", "39.739200"); + map.put("APP_RTOTA_LON", "-104.990300"); + + QSLRecord r = new QSLRecord(map); + + assertThat(r.getMyLat()).isWithin(1e-9).of(39.7392); + assertThat(r.getMyLon()).isWithin(1e-9).of(-104.9903); + } + + @Test + public void mapConstructor_currentAppFieldsWinOverTheLegacyPair() { + // A record carrying both (an old export re-exported by a new build) + // must not resolve to the stale copy. + HashMap map = new HashMap<>(); + map.put("CALL", "W1AW"); + map.put("COMMENT", "imported"); + map.put("APP_ROTA_LAT", "39.739200"); + map.put("APP_ROTA_LON", "-104.990300"); + map.put("APP_RTOTA_LAT", "1.000000"); + map.put("APP_RTOTA_LON", "2.000000"); + + QSLRecord r = new QSLRecord(map); + + assertThat(r.getMyLat()).isWithin(1e-9).of(39.7392); + assertThat(r.getMyLon()).isWithin(1e-9).of(-104.9903); + } + + @Test + public void mapConstructor_fallsBackToStandardFieldsWhenNoAppPairIsPresent() { + HashMap map = new HashMap<>(); + map.put("CALL", "W1AW"); + map.put("COMMENT", "imported"); + map.put("MY_LAT", "N039 44.352"); + map.put("MY_LON", "W104 59.418"); + + QSLRecord r = new QSLRecord(map); + + // The Location datatype quantizes to a thousandth of a minute. + assertThat(r.getMyLat()).isWithin(1e-4).of(39.7392); + assertThat(r.getMyLon()).isWithin(1e-4).of(-104.9903); + } + @Test public void mapConstructor_qslAndPotaFields() { HashMap map = new HashMap<>(); diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/location/RoverPositionTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/location/RoverPositionTest.kt index cdeda9168..142396792 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/location/RoverPositionTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/location/RoverPositionTest.kt @@ -10,7 +10,7 @@ import org.junit.Test * beats freshness, because a live trip fix is a better account of the drive than * a last-known location of unknown provenance even when it is older. And when * every candidate is stale the answer is *nothing* — an unlocated QSO is honest, - * and rtota.app places it from the breadcrumb trail on upload. + * and roadsontheair.com places it from the breadcrumb trail on upload. */ class RoverPositionTest { private fun fix(source: RoverPositionSource, ageMs: Long) = @@ -57,7 +57,7 @@ class RoverPositionTest { fun `every stale candidate means no position at all, never an approximation`() { // No grid-centre tier exists on purpose: a 4-character grid is ~55 km across, // and its centre written into MY_LAT/MY_LON would read as a measurement. - // rtota.app infers a position from the breadcrumb trail instead. + // roadsontheair.com infers a position from the breadcrumb trail instead. val chosen = chooseRoverFix( listOf( diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/HighwayCachePolicyTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/HighwayCachePolicyTest.kt similarity index 99% rename from ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/HighwayCachePolicyTest.kt rename to ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/HighwayCachePolicyTest.kt index b9a9a349c..1d876dbce 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/HighwayCachePolicyTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/HighwayCachePolicyTest.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import com.google.common.truth.Truth.assertThat import org.junit.Test diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/HighwayClassifierTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/HighwayClassifierTest.kt similarity index 99% rename from ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/HighwayClassifierTest.kt rename to ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/HighwayClassifierTest.kt index f7a811fb7..c4d3ff782 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/HighwayClassifierTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/HighwayClassifierTest.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import com.google.common.truth.Truth.assertThat import org.junit.Test diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RoadTripScreenLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RoadTripScreenLogicTest.kt similarity index 66% rename from ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RoadTripScreenLogicTest.kt rename to ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RoadTripScreenLogicTest.kt index 304c5fbb3..92b8111c2 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RoadTripScreenLogicTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RoadTripScreenLogicTest.kt @@ -1,12 +1,12 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import radio.ks3ckc.ft8af.ui.rtota.describeProfile -import radio.ks3ckc.ft8af.ui.rtota.maskKey -import radio.ks3ckc.ft8af.ui.rtota.nextPrivacy +import radio.ks3ckc.ft8af.ui.rota.describeProfile +import radio.ks3ckc.ft8af.ui.rota.maskKey +import radio.ks3ckc.ft8af.ui.rota.nextPrivacy /** * The decision logic pulled out of the Compose screen (tap-to-cycle rows, key @@ -17,11 +17,20 @@ import radio.ks3ckc.ft8af.ui.rtota.nextPrivacy class RoadTripScreenLogicTest { @Test fun `key masking never shows a usable credential`() { + assertThat(maskKey("rota_1234567890abcdef")).isEqualTo("rota_…cdef") + // Keys issued before the rename carry the longer prefix; the mask has to + // cut at the separator for both, or one of them leaks a secret character. assertThat(maskKey("rtota_1234567890abcdef")).isEqualTo("rtota_…cdef") // Short enough to be a typo rather than a key: show nothing at all. assertThat(maskKey("abc")).isEqualTo("••••") } + @Test + fun `key masking falls back to a fixed head when there is no prefix`() { + // A self-hosted server is free to mint unprefixed keys. + assertThat(maskKey("1234567890abcdef")).isEqualTo("123456…cdef") + } + @Test fun `privacy cycles through every level and back to account default`() { var p = "" @@ -51,23 +60,23 @@ class RoadTripScreenLogicTest { @Test fun `notification summarises the trip`() { val text = - RtotaTripService.notificationText( - RtotaTripState(miles = 142.34, sentQsos = 30, pendingQsos = 7, pendingPoints = 5), + RotaTripService.notificationText( + RotaTripState(miles = 142.34, sentQsos = 30, pendingQsos = 7, pendingPoints = 5), ) assertThat(text).isEqualTo("142.3 mi · 37 QSOs · 12 waiting") } @Test fun `notification stays quiet when everything is uploaded`() { - val text = RtotaTripService.notificationText(RtotaTripState(miles = 12.0, sentQsos = 3)) + val text = RotaTripService.notificationText(RotaTripState(miles = 12.0, sentQsos = 3)) assertThat(text).isEqualTo("12.0 mi · 3 QSOs") } @Test fun `notification says parked so a quiet trail does not read as broken`() { val text = - RtotaTripService.notificationText( - RtotaTripState(miles = 88.0, sentQsos = 12, parked = true), + RotaTripService.notificationText( + RotaTripState(miles = 88.0, sentQsos = 12, parked = true), ) assertThat(text).isEqualTo("88.0 mi · 12 QSOs · parked") } @@ -75,8 +84,8 @@ class RoadTripScreenLogicTest { @Test fun `notification flags an offline start and the last error`() { val text = - RtotaTripService.notificationText( - RtotaTripState(miles = 0.4, pendingCreate = true, lastError = "no route to host"), + RotaTripService.notificationText( + RotaTripState(miles = 0.4, pendingCreate = true, lastError = "no route to host"), ) assertThat(text).contains("offline start") assertThat(text).contains("no route to host") diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaActivationTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaActivationTest.kt similarity index 97% rename from ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaActivationTest.kt rename to ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaActivationTest.kt index e4febe437..af6f7b933 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaActivationTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaActivationTest.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -18,7 +18,7 @@ import org.robolectric.RobolectricTestRunner * Robolectric because org.json is an Android stub on the plain JVM classpath. */ @RunWith(RobolectricTestRunner::class) -class RtotaActivationTest { +class RotaActivationTest { // 2026-08-01T22:00:00Z private val start = 1_785_621_600_000L private val hour = 3_600_000L @@ -26,7 +26,7 @@ class RtotaActivationTest { private fun plan( startMs: Long = start, endMs: Long? = start + 8 * hour, - ) = RtotaActivation("id", "Shakedown", startMs, endMs, null) + ) = RotaActivation("id", "Shakedown", startMs, endMs, null) @Test fun `parses the plans an api-me body carries, soonest first`() { diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaBaseUrlTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaBaseUrlTest.kt new file mode 100644 index 000000000..f67326c65 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaBaseUrlTest.kt @@ -0,0 +1,85 @@ +package radio.ks3ckc.ft8af.rota + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Base-URL normalization. + * + * The apex case is not cosmetic: `roadsontheair.com` answers with a 308, no HTTP client + * may auto-follow a 308 for a POST, and [isRetryableRotaFailure] classifies a + * 3xx as fatal — so a single wrong host turns every upload of a trip into a + * permanent failure with a full queue behind it. + */ +class RotaBaseUrlTest { + @Test + fun `the apex host is rewritten to www so POSTs are not 308ed`() { + assertThat(normalizeRotaBaseUrl("https://roadsontheair.com")).isEqualTo("https://www.roadsontheair.com") + assertThat(normalizeRotaBaseUrl("http://roadsontheair.com")).isEqualTo("http://www.roadsontheair.com") + assertThat(normalizeRotaBaseUrl("https://ROADSONTHEAIR.COM")).isEqualTo("https://www.roadsontheair.com") + } + + @Test + fun `the default is already the www host`() { + assertThat(RotaSettings.DEFAULT_BASE_URL).isEqualTo("https://www.roadsontheair.com") + assertThat(normalizeRotaBaseUrl(RotaSettings.DEFAULT_BASE_URL)) + .isEqualTo(RotaSettings.DEFAULT_BASE_URL) + } + + @Test + fun `a bare host gets https rather than throwing at request time`() { + assertThat(normalizeRotaBaseUrl("roadsontheair.com")).isEqualTo("https://www.roadsontheair.com") + assertThat(normalizeRotaBaseUrl("dev.example.com")).isEqualTo("https://dev.example.com") + } + + @Test + fun `trailing slashes and whitespace are stripped so paths append cleanly`() { + assertThat(normalizeRotaBaseUrl(" https://www.roadsontheair.com/ ")).isEqualTo("https://www.roadsontheair.com") + assertThat(normalizeRotaBaseUrl("https://roadsontheair.com///")).isEqualTo("https://www.roadsontheair.com") + } + + @Test + fun `a self-hosted or dev origin is left exactly as typed`() { + // Nothing here knows how someone else's origin is fronted, so guessing + // would be worse than leaving it alone. + assertThat(normalizeRotaBaseUrl("http://192.168.1.50:3000")).isEqualTo("http://192.168.1.50:3000") + assertThat(normalizeRotaBaseUrl("https://rota.example.org")).isEqualTo("https://rota.example.org") + } + + @Test + fun `only the apex host matches, not a lookalike or a path`() { + assertThat(normalizeRotaBaseUrl("https://myroadsontheair.com")).isEqualTo("https://myroadsontheair.com") + assertThat(normalizeRotaBaseUrl("https://staging.roadsontheair.com")).isEqualTo("https://staging.roadsontheair.com") + assertThat(normalizeRotaBaseUrl("https://example.com/roadsontheair.com")).isEqualTo("https://example.com/roadsontheair.com") + } + + @Test + fun `a host with a port or path keeps them while the host is fixed`() { + assertThat(normalizeRotaBaseUrl("https://roadsontheair.com/base")).isEqualTo("https://www.roadsontheair.com/base") + } + + @Test + fun `the pre-rename rtota_app origin is repointed at the current domain`() { + // An install configured before the Roads On The Air rename has the old + // origin sitting in its prefs. Normalizing on read is the only thing + // standing between that install and every upload failing against a host + // we no longer serve. + assertThat(normalizeRotaBaseUrl("https://www.rtota.app")).isEqualTo("https://www.roadsontheair.com") + assertThat(normalizeRotaBaseUrl("https://rtota.app")).isEqualTo("https://www.roadsontheair.com") + assertThat(normalizeRotaBaseUrl("rtota.app")).isEqualTo("https://www.roadsontheair.com") + assertThat(normalizeRotaBaseUrl("https://RTOTA.APP")).isEqualTo("https://www.roadsontheair.com") + assertThat(normalizeRotaBaseUrl("https://www.rtota.app/base")).isEqualTo("https://www.roadsontheair.com/base") + } + + @Test + fun `a lookalike of the old domain is still left alone`() { + assertThat(normalizeRotaBaseUrl("https://myrtota.app")).isEqualTo("https://myrtota.app") + assertThat(normalizeRotaBaseUrl("https://staging.rtota.app")).isEqualTo("https://staging.rtota.app") + } + + @Test + fun `empty stays empty so the caller can fall back to the default`() { + assertThat(normalizeRotaBaseUrl("")).isEmpty() + assertThat(normalizeRotaBaseUrl(" ")).isEmpty() + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCallsignTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaCallsignTest.kt similarity index 96% rename from ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCallsignTest.kt rename to ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaCallsignTest.kt index 273ab71e1..6a409e39f 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCallsignTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaCallsignTest.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import com.google.common.truth.Truth.assertThat import org.junit.After @@ -14,7 +14,7 @@ import java.util.Locale * a callsign the server can never match, while the rest of the feature * normalizes with `Locale.US` and disagrees with the setting that produced it. */ -class RtotaCallsignTest { +class RotaCallsignTest { private val original: Locale = Locale.getDefault() @After diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClientTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaClientTest.kt similarity index 72% rename from ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClientTest.kt rename to ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaClientTest.kt index 82897fc38..26bd31541 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaClientTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaClientTest.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.runBlocking @@ -14,12 +14,12 @@ import java.io.IOException /** * Request shape and failure classification against a stub server. What is being - * pinned here is the contract with rtota.app: the paths, the Bearer header, and + * pinned here is the contract with roadsontheair.com: the paths, the Bearer header, and * — most importantly — which failures are worth retrying from a moving vehicle * and which are the operator's problem to fix. */ @RunWith(RobolectricTestRunner::class) -class RtotaClientTest { +class RotaClientTest { private lateinit var server: MockWebServer private lateinit var baseUrl: String @@ -39,11 +39,11 @@ class RtotaClientTest { fun `registerOperator posts the callsign and returns the key`() = runBlocking { server.enqueue( - MockResponse().setBody("""{"id":"op1","callsign":"K1AF","apiKey":"rtota_abc123"}"""), + MockResponse().setBody("""{"id":"op1","callsign":"K1AF","apiKey":"rota_abc123"}"""), ) - val result = RtotaClient.registerOperator(baseUrl, "k1af", homeGrid = "DM79") + val result = RotaClient.registerOperator(baseUrl, "k1af", homeGrid = "DM79") - assertThat(result.getOrNull()).isEqualTo("rtota_abc123") + assertThat(result.getOrNull()).isEqualTo("rota_abc123") val request = server.takeRequest() assertThat(request.path).isEqualTo("/api/operators") assertThat(request.method).isEqualTo("POST") @@ -59,13 +59,13 @@ class RtotaClientTest { MockResponse().setResponseCode(409) .setBody("""{"error":"That callsign is already registered."}"""), ) - val error = RtotaClient.registerOperator(baseUrl, "K1AF").exceptionOrNull() + val error = RotaClient.registerOperator(baseUrl, "K1AF").exceptionOrNull() - assertThat(error).isInstanceOf(RtotaHttpException::class.java) - assertThat((error as RtotaHttpException).serverMessage) + assertThat(error).isInstanceOf(RotaHttpException::class.java) + assertThat((error as RotaHttpException).serverMessage) .isEqualTo("That callsign is already registered.") // A 409 is the user's to resolve — retrying would fail identically forever. - assertThat(isRetryableRtotaFailure(error)).isFalse() + assertThat(isRetryableRotaFailure(error)).isFalse() } @Test @@ -76,17 +76,17 @@ class RtotaClientTest { .setBody("""{"id":"trip-1","shareToken":"tok","status":"active"}"""), ) val handle = - RtotaClient.createTrip( + RotaClient.createTrip( baseUrl = baseUrl, - apiKey = "rtota_key", + apiKey = "rota_key", name = "Route 66", startTimeMs = 1_753_970_709_000L, privacy = "delayed", ).getOrNull() - assertThat(handle).isEqualTo(RtotaTripHandle("trip-1", "tok")) + assertThat(handle).isEqualTo(RotaTripHandle("trip-1", "tok")) val request = server.takeRequest() - assertThat(request.getHeader("Authorization")).isEqualTo("Bearer rtota_key") + assertThat(request.getHeader("Authorization")).isEqualTo("Bearer rota_key") val body = JSONObject(request.body.readUtf8()) assertThat(body.getString("name")).isEqualTo("Route 66") assertThat(body.getString("startTime")).isEqualTo("2025-07-31T14:05:09.000Z") @@ -102,11 +102,11 @@ class RtotaClientTest { ), ) val batch = - RtotaBatch( + RotaBatch( points = listOf(TripPoint(1_753_970_709_000L, 39.0, -105.0)), qsos = listOf(TripQso("K1AF", 1_753_970_709_000L, band = "20m", mode = "FT8")), ) - val ack = RtotaClient.sendLive(baseUrl, "k", "trip-1", batch).getOrNull() + val ack = RotaClient.sendLive(baseUrl, "k", "trip-1", batch).getOrNull() assertThat(ack?.qsosInserted).isEqualTo(1) val request = server.takeRequest() @@ -121,15 +121,15 @@ class RtotaClientTest { fun `a live batch the server accepts with no body still reports success`() = runBlocking { server.enqueue(MockResponse().setBody("")) - val batch = RtotaBatch(listOf(TripPoint(1L, 39.0, -105.0)), emptyList()) - assertThat(RtotaClient.sendLive(baseUrl, "k", "t", batch).isSuccess).isTrue() + val batch = RotaBatch(listOf(TripPoint(1L, 39.0, -105.0)), emptyList()) + assertThat(RotaClient.sendLive(baseUrl, "k", "t", batch).isSuccess).isTrue() } @Test fun `completeTrip hits the complete endpoint`() = runBlocking { server.enqueue(MockResponse().setBody("""{"status":"completed"}""")) - assertThat(RtotaClient.completeTrip(baseUrl, "k", "trip-1").isSuccess).isTrue() + assertThat(RotaClient.completeTrip(baseUrl, "k", "trip-1").isSuccess).isTrue() assertThat(server.takeRequest().path).isEqualTo("/api/trips/trip-1/complete") } @@ -138,7 +138,7 @@ class RtotaClientTest { runBlocking { server.enqueue(MockResponse().setResponseCode(201).setBody("""{"id":"act-1"}""")) val id = - RtotaClient.createActivation( + RotaClient.createActivation( baseUrl = baseUrl, apiKey = "k", title = "I-70 westbound", @@ -155,21 +155,21 @@ class RtotaClientTest { @Test fun `a server error is retryable, a rejected request is not`() { - assertThat(isRetryableRtotaFailure(RtotaHttpException(503, ""))).isTrue() - assertThat(isRetryableRtotaFailure(RtotaHttpException(429, ""))).isTrue() - assertThat(isRetryableRtotaFailure(IOException("no route to host"))).isTrue() - assertThat(isRetryableRtotaFailure(RtotaHttpException(401, ""))).isFalse() - assertThat(isRetryableRtotaFailure(RtotaHttpException(403, ""))).isFalse() - assertThat(isRetryableRtotaFailure(IllegalStateException("bad parse"))).isFalse() + assertThat(isRetryableRotaFailure(RotaHttpException(503, ""))).isTrue() + assertThat(isRetryableRotaFailure(RotaHttpException(429, ""))).isTrue() + assertThat(isRetryableRotaFailure(IOException("no route to host"))).isTrue() + assertThat(isRetryableRotaFailure(RotaHttpException(401, ""))).isFalse() + assertThat(isRetryableRotaFailure(RotaHttpException(403, ""))).isFalse() + assertThat(isRetryableRotaFailure(IllegalStateException("bad parse"))).isFalse() } @Test fun `backoff grows and then holds at fifteen minutes`() { - assertThat(rtotaBackoffMs(1)).isEqualTo(30_000L) - assertThat(rtotaBackoffMs(2)).isEqualTo(60_000L) - assertThat(rtotaBackoffMs(3)).isEqualTo(120_000L) - assertThat(rtotaBackoffMs(6)).isEqualTo(15 * 60_000L) + assertThat(rotaBackoffMs(1)).isEqualTo(30_000L) + assertThat(rotaBackoffMs(2)).isEqualTo(60_000L) + assertThat(rotaBackoffMs(3)).isEqualTo(120_000L) + assertThat(rotaBackoffMs(6)).isEqualTo(15 * 60_000L) // Past the cap the wait must not keep doubling toward hours. - assertThat(rtotaBackoffMs(50)).isEqualTo(15 * 60_000L) + assertThat(rotaBackoffMs(50)).isEqualTo(15 * 60_000L) } } diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCqModifierTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaCqModifierTest.kt similarity index 65% rename from ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCqModifierTest.kt rename to ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaCqModifierTest.kt index 63a651faa..2141f8668 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaCqModifierTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaCqModifierTest.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -6,24 +6,25 @@ import org.junit.Test /** * The on-air token and the save/restore rules around it. * - * The encodability check is the one that justifies the whole file: "RTOTA" is - * the name of the program but not a legal FT8 CQ modifier, and a token the + * The encodability check is the one that justifies the whole file: a token the * message formatter silently drops produces a bare "CQ " that looks - * completely normal in the log — an on-air failure nobody notices until the - * trip is over. These assertions pin the difference. + * completely normal in the log — an on-air failure nobody notices until the trip + * is over. These assertions pin the token to something that actually encodes. */ -class RtotaCqModifierTest { +class RotaCqModifierTest { @Test - fun `the trip token is four letters so it survives FT8 encoding`() { - assertThat(RTOTA_CQ_MODIFIER).isEqualTo("RTOA") - assertThat(isEncodableCqModifier(RTOTA_CQ_MODIFIER)).isTrue() + fun `the trip token is the program name and survives FT8 encoding`() { + assertThat(ROTA_CQ_MODIFIER).isEqualTo("ROTA") + assertThat(isEncodableCqModifier(ROTA_CQ_MODIFIER)).isTrue() } @Test - fun `RTOTA itself cannot be encoded`() { - // Five letters — no encoding exists in the 77-bit standard message, which - // is exactly why the token above is not simply the program's name. + fun `the pre-rename name is why the token used to be scrambled`() { + // RTOTA is five letters and has no encoding in the 77-bit standard + // message, so the token had to be the anagram RTOA. Renaming the program + // to ROTA — four letters, like POTA — is what retired that workaround. assertThat(isEncodableCqModifier("RTOTA")).isFalse() + assertThat(isEncodableCqModifier("RTOA")).isTrue() } @Test @@ -50,22 +51,22 @@ class RtotaCqModifierTest { @Test fun `re-applying the trip token does not make it its own predecessor`() { - // The regression this guards: a restore that banked "RTOA" as the value to + // The regression this guards: a restore that banked "ROTA" as the value to // restore would leave the road-trip token set forever after the trip ended. - assertThat(modifierToRemember(RTOTA_CQ_MODIFIER)).isEqualTo("") + assertThat(modifierToRemember(ROTA_CQ_MODIFIER)).isEqualTo("") } @Test fun `ending a trip restores what the operator had before it`() { - assertThat(modifierAfterTripEnd(RTOTA_CQ_MODIFIER, "NA")).isEqualTo("NA") - assertThat(modifierAfterTripEnd(RTOTA_CQ_MODIFIER, "")).isEqualTo("") + assertThat(modifierAfterTripEnd(ROTA_CQ_MODIFIER, "NA")).isEqualTo("NA") + assertThat(modifierAfterTripEnd(ROTA_CQ_MODIFIER, "")).isEqualTo("") } @Test fun `ending a trip leaves a POTA activation's modifier alone`() { // A park activation started mid-trip owns the modifier now. Restoring over // it would put the operator back on a plain CQ mid-activation, and POTA's - // own end would then hand "RTOA" back for a trip that no longer exists. + // own end would then hand "ROTA" back for a trip that no longer exists. assertThat(modifierAfterTripEnd("POTA", "NA")).isNull() assertThat(modifierAfterTripEnd("", "NA")).isNull() assertThat(modifierAfterTripEnd(null, "NA")).isNull() diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaPayloadTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaPayloadTest.kt similarity index 99% rename from ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaPayloadTest.kt rename to ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaPayloadTest.kt index 56598c5e5..4cc0c5ffc 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaPayloadTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaPayloadTest.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import com.google.common.truth.Truth.assertThat import org.json.JSONObject @@ -16,7 +16,7 @@ import org.robolectric.RobolectricTestRunner * ones — a wrapping heading, a rig reporting 0 Hz, a missing report. */ @RunWith(RobolectricTestRunner::class) -class RtotaPayloadTest { +class RotaPayloadTest { private val ts = 1_753_970_709_000L // 2025-07-31T14:05:09Z @Test diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQsoMapperTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaQsoMapperTest.kt similarity index 94% rename from ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQsoMapperTest.kt rename to ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaQsoMapperTest.kt index 36c003981..97200bd25 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQsoMapperTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaQsoMapperTest.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -7,13 +7,13 @@ import org.junit.Test * QSLRecord → live QSO mapping. Exercised through the pure entry point so no * QSLRecord (and therefore no GeneralVariables / rig layer) is needed. */ -class RtotaQsoMapperTest { +class RotaQsoMapperTest { private val now = 1_753_970_709_000L @Test fun `a normal contact maps every field`() { val qso = - RtotaQsoMapper.buildTripQso( + RotaQsoMapper.buildTripQso( toCallsign = "k1af", qsoDate = "20250731", timeOn = "140509", @@ -44,7 +44,7 @@ class RtotaQsoMapperTest { @Test fun `TIME_ON wins over TIME_OFF so live and ADIF copies dedupe against each other`() { val qso = - RtotaQsoMapper.buildTripQso( + RotaQsoMapper.buildTripQso( toCallsign = "K1AF", qsoDate = "20250731", timeOn = "140509", @@ -67,7 +67,7 @@ class RtotaQsoMapperTest { @Test fun `an unusable TIME_ON falls back to the off time`() { val qso = - RtotaQsoMapper.buildTripQso( + RotaQsoMapper.buildTripQso( toCallsign = "K1AF", qsoDate = "", timeOn = "", @@ -90,7 +90,7 @@ class RtotaQsoMapperTest { @Test fun `a record with no usable timestamps falls back to now`() { val qso = - RtotaQsoMapper.buildTripQso( + RotaQsoMapper.buildTripQso( toCallsign = "K1AF", qsoDate = null, timeOn = null, @@ -113,7 +113,7 @@ class RtotaQsoMapperTest { @Test fun `an SWL record with no reports omits them instead of sending the sentinel`() { val qso = - RtotaQsoMapper.buildTripQso( + RotaQsoMapper.buildTripQso( toCallsign = "K1AF", qsoDate = "20250731", timeOn = "140509", @@ -140,7 +140,7 @@ class RtotaQsoMapperTest { @Test fun `a record with no callsign is not queued at all`() { val qso = - RtotaQsoMapper.buildTripQso( + RotaQsoMapper.buildTripQso( toCallsign = " ", qsoDate = "20250731", timeOn = "140509", diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQueueTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaQueueTest.kt similarity index 82% rename from ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQueueTest.kt rename to ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaQueueTest.kt index 3058da59c..cd41b6da3 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaQueueTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaQueueTest.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import com.google.common.truth.Truth.assertThat import org.junit.Rule @@ -14,7 +14,7 @@ import java.io.File * must drop exactly what the server took and not a row more. */ @RunWith(RobolectricTestRunner::class) -class RtotaQueueTest { +class RotaQueueTest { @get:Rule val temp = TemporaryFolder() @@ -24,11 +24,11 @@ class RtotaQueueTest { private fun qso(i: Int) = TripQso("K1AF$i", base + i * 1000L, band = "20m", mode = "FT8") - private fun queueFile(): File = File(temp.newFolder(), "rtota_queue.json") + private fun queueFile(): File = File(temp.newFolder(), "rota_queue.json") @Test fun `pruning drops only the contacts the server reported holding`() { - val q = RtotaQueue(queueFile()) + val q = RotaQueue(queueFile()) repeat(3) { q.addQso(qso(it)) } repeat(2) { q.addPoint(point(it)) } @@ -45,18 +45,18 @@ class RtotaQueueTest { @Test fun `pruning survives a restart, so a resumed trip does not re-send what it dropped`() { val file = queueFile() - RtotaQueue(file).apply { + RotaQueue(file).apply { repeat(3) { addQso(qso(it)) } pruneAcknowledgedQsos(setOf(qso(0).dedupeKey())) } - val reloaded = RtotaQueue(file).also { it.load() } + val reloaded = RotaQueue(file).also { it.load() } assertThat(reloaded.qsoCount()).isEqualTo(2) } @Test fun `an unknown or empty key set removes nothing`() { // A failed handshake must never be read as "the server has everything". - val q = RtotaQueue(queueFile()) + val q = RotaQueue(queueFile()) repeat(2) { q.addQso(qso(it)) } assertThat(q.pruneAcknowledgedQsos(emptySet())).isEqualTo(0) assertThat(q.pruneAcknowledgedQsos(setOf("W9XYZ|20M|FT8|2023-11-14T22:13"))).isEqualTo(0) @@ -65,7 +65,7 @@ class RtotaQueueTest { @Test fun `counts track what was added`() { - val q = RtotaQueue(queueFile()) + val q = RotaQueue(queueFile()) q.addPoint(point(1)) q.addPoint(point(2)) q.addQso(qso(1)) @@ -76,7 +76,7 @@ class RtotaQueueTest { @Test fun `batch takes the oldest first and leaves the queue intact`() { - val q = RtotaQueue(queueFile()) + val q = RotaQueue(queueFile()) repeat(5) { q.addPoint(point(it)) } val batch = q.peekBatch(maxPoints = 3, maxQsos = 3) assertThat(batch.points).hasSize(3) @@ -86,7 +86,7 @@ class RtotaQueueTest { @Test fun `commit removes exactly the batch and keeps what arrived during the upload`() { - val q = RtotaQueue(queueFile()) + val q = RotaQueue(queueFile()) repeat(3) { q.addPoint(point(it)) } val batch = q.peekBatch() // A fix that lands while the POST is in flight must survive the commit. @@ -99,11 +99,11 @@ class RtotaQueueTest { @Test fun `a queue survives a process restart`() { val file = queueFile() - val first = RtotaQueue(file) + val first = RotaQueue(file) repeat(4) { first.addPoint(point(it)) } first.addQso(qso(1)) - val second = RtotaQueue(file).apply { load() } + val second = RotaQueue(file).apply { load() } assertThat(second.pointCount()).isEqualTo(4) assertThat(second.qsoCount()).isEqualTo(1) assertThat(second.peekBatch().qsos.single().callsign).isEqualTo("K1AF1") @@ -113,22 +113,22 @@ class RtotaQueueTest { fun `a truncated queue file starts empty rather than throwing`() { val file = queueFile() file.writeText("""{"points":[{"t":1,""") - val q = RtotaQueue(file).apply { load() } + val q = RotaQueue(file).apply { load() } assertThat(q.isEmpty()).isTrue() } @Test fun `points are capped, dropping the oldest`() { - val q = RtotaQueue(null) - repeat(RtotaQueue.MAX_POINTS + 10) { q.addPoint(point(it)) } - assertThat(q.pointCount()).isEqualTo(RtotaQueue.MAX_POINTS) + val q = RotaQueue(null) + repeat(RotaQueue.MAX_POINTS + 10) { q.addPoint(point(it)) } + assertThat(q.pointCount()).isEqualTo(RotaQueue.MAX_POINTS) // The head is now the 11th point ever added. assertThat(q.peekBatch(maxPoints = 1).points.single()).isEqualTo(point(10)) } @Test fun `clear empties both halves`() { - val q = RtotaQueue(queueFile()) + val q = RotaQueue(queueFile()) q.addPoint(point(1)) q.addQso(qso(1)) q.clear() @@ -137,9 +137,9 @@ class RtotaQueueTest { @Test fun `committing more than the queue holds does not underflow`() { - val q = RtotaQueue(null) + val q = RotaQueue(null) q.addPoint(point(1)) - val oversized = RtotaBatch(listOf(point(1), point(2), point(3)), emptyList()) + val oversized = RotaBatch(listOf(point(1), point(2), point(3)), emptyList()) q.commit(oversized) assertThat(q.isEmpty()).isTrue() } diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaSyncStateTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaSyncStateTest.kt similarity index 98% rename from ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaSyncStateTest.kt rename to ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaSyncStateTest.kt index 17315dd36..0eb767df1 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaSyncStateTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaSyncStateTest.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -18,7 +18,7 @@ import org.robolectric.RobolectricTestRunner * Robolectric because org.json is an Android stub on the plain JVM classpath. */ @RunWith(RobolectricTestRunner::class) -class RtotaSyncStateTest { +class RotaSyncStateTest { private val ts = 1_753_970_709_000L // 2025-07-31T14:05:09Z @Test diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconRouteFidelityTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/SmartBeaconRouteFidelityTest.kt similarity index 99% rename from ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconRouteFidelityTest.kt rename to ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/SmartBeaconRouteFidelityTest.kt index 0f0b76ab2..8feeeec31 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconRouteFidelityTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/SmartBeaconRouteFidelityTest.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import com.google.common.truth.Truth.assertThat import org.junit.Test diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconSamplerTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/SmartBeaconSamplerTest.kt similarity index 99% rename from ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconSamplerTest.kt rename to ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/SmartBeaconSamplerTest.kt index 175e99fbf..aa15c91bf 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/SmartBeaconSamplerTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/SmartBeaconSamplerTest.kt @@ -1,4 +1,4 @@ -package radio.ks3ckc.ft8af.rtota +package radio.ks3ckc.ft8af.rota import com.google.common.truth.Truth.assertThat import org.junit.Test diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaBaseUrlTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaBaseUrlTest.kt deleted file mode 100644 index f29bf583b..000000000 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rtota/RtotaBaseUrlTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -package radio.ks3ckc.ft8af.rtota - -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -/** - * Base-URL normalization. - * - * The apex case is not cosmetic: `rtota.app` answers with a 308, no HTTP client - * may auto-follow a 308 for a POST, and [isRetryableRtotaFailure] classifies a - * 3xx as fatal — so a single wrong host turns every upload of a trip into a - * permanent failure with a full queue behind it. - */ -class RtotaBaseUrlTest { - @Test - fun `the apex host is rewritten to www so POSTs are not 308ed`() { - assertThat(normalizeRtotaBaseUrl("https://rtota.app")).isEqualTo("https://www.rtota.app") - assertThat(normalizeRtotaBaseUrl("http://rtota.app")).isEqualTo("http://www.rtota.app") - assertThat(normalizeRtotaBaseUrl("https://RTOTA.APP")).isEqualTo("https://www.rtota.app") - } - - @Test - fun `the default is already the www host`() { - assertThat(RtotaSettings.DEFAULT_BASE_URL).isEqualTo("https://www.rtota.app") - assertThat(normalizeRtotaBaseUrl(RtotaSettings.DEFAULT_BASE_URL)) - .isEqualTo(RtotaSettings.DEFAULT_BASE_URL) - } - - @Test - fun `a bare host gets https rather than throwing at request time`() { - assertThat(normalizeRtotaBaseUrl("rtota.app")).isEqualTo("https://www.rtota.app") - assertThat(normalizeRtotaBaseUrl("dev.example.com")).isEqualTo("https://dev.example.com") - } - - @Test - fun `trailing slashes and whitespace are stripped so paths append cleanly`() { - assertThat(normalizeRtotaBaseUrl(" https://www.rtota.app/ ")).isEqualTo("https://www.rtota.app") - assertThat(normalizeRtotaBaseUrl("https://rtota.app///")).isEqualTo("https://www.rtota.app") - } - - @Test - fun `a self-hosted or dev origin is left exactly as typed`() { - // Nothing here knows how someone else's origin is fronted, so guessing - // would be worse than leaving it alone. - assertThat(normalizeRtotaBaseUrl("http://192.168.1.50:3000")).isEqualTo("http://192.168.1.50:3000") - assertThat(normalizeRtotaBaseUrl("https://rtota.example.org")).isEqualTo("https://rtota.example.org") - } - - @Test - fun `only the apex host matches, not a lookalike or a path`() { - assertThat(normalizeRtotaBaseUrl("https://myrtota.app")).isEqualTo("https://myrtota.app") - assertThat(normalizeRtotaBaseUrl("https://staging.rtota.app")).isEqualTo("https://staging.rtota.app") - assertThat(normalizeRtotaBaseUrl("https://example.com/rtota.app")).isEqualTo("https://example.com/rtota.app") - } - - @Test - fun `a host with a port or path keeps them while the host is fixed`() { - assertThat(normalizeRtotaBaseUrl("https://rtota.app/base")).isEqualTo("https://www.rtota.app/base") - } - - @Test - fun `empty stays empty so the caller can fall back to the default`() { - assertThat(normalizeRtotaBaseUrl("")).isEmpty() - assertThat(normalizeRtotaBaseUrl(" ")).isEmpty() - } -} From efa264ea85730820d4ad9add974cf0c069e838fb Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sun, 2 Aug 2026 07:33:18 -0500 Subject: [PATCH 107/113] Start the announced trip instead of creating one beside it (#717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Start the announced trip instead of creating one beside it roadsontheair.com folded announcements into the trips table as a `planned` status, and deleted /api/activations. Three things follow. The announce call moves to `POST /api/trips` with `status: "planned"` and `name` (was `title`). The old path is a 404 now. `parseMyPlannedTrips` reads `plannedTrips[].name` off /api/me, was `activations[].title`. This one failed *silently*: the parser turns an unrecognized shape into an empty list, so the picker rendered "no upcoming trips" rather than an error. The keys are pinned in a test for that reason, including one asserting the old shape yields nothing. Picking a plan now binds. The plan is the same row the trip will be driven as, so Start promotes it by id (`POST /api/trips/:id/start`) rather than creating a second trip and leaving the announcement at `planned` forever. The wizard's privacy — delay, route trim, replay lock — therefore applies by construction. It used to be name-only, with the server guessing which plan a trip fulfilled from departure times within ±12 h; driving outside that window silently fell back to the account default. `activationMatchesNow` and the picker's window warning mirrored that guess and are gone. Promotion respects the deferred-create design, so a rover can still pull out of a driveway with no signal: the flush loop starts the plan when the network allows. Two failures are rover-normal rather than errors — 409 (a retry that actually landed, or another device) adopts the row, and 404 (the plan was cancelled on the site while out of coverage) falls back to a plain trip so the drive isn't stranded behind a queue that never drains. Co-Authored-By: Claude Opus 5 (1M context) * Ask the server what an adopted trip holds (PR #717 Copilot review) The 409 branch said it would "let the resume handshake below establish what the server holds", but never armed it. `resumeHandshakePending` is only set in `restore()`, and set to `tripId.isNotEmpty()` — a trip still at `tripPendingCreate` has an empty id, so it is false there too, and `startTrip()` sets it false outright. Every path that reaches the 409 is therefore a path that skips the handshake. Adoption is exactly the case the handshake exists for. Its own doc draws the line at "did this process start the row" — and on a 409 it did not: another device started the plan, or a start whose answer we never saw landed. That row's contents are as unknown as one resumed from disk, so the queue should not be shipped without asking. The GET is cheap and already best-effort, so a failure still doesn't touch the backoff. The decision goes in a named function next to the outcome it reads from, with a test pinning the set of outcomes that arm it, so a fourth outcome has to answer the question rather than inherit "no handshake". Also fixes the KDoc on startPlannedTrip: the property is `httpCode`, not `code`, so the link didn't resolve. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../radio/ks3ckc/ft8af/rota/RotaClient.kt | 120 +++++++++++++-- .../radio/ks3ckc/ft8af/rota/RotaModels.kt | 67 +++------ .../radio/ks3ckc/ft8af/rota/RotaSettings.kt | 19 ++- .../ks3ckc/ft8af/rota/RotaTripManager.kt | 77 ++++++++-- .../ks3ckc/ft8af/ui/rota/RoadTripScreen.kt | 65 ++++---- .../src/main/res/values/strings_compose.xml | 3 +- .../ks3ckc/ft8af/rota/RotaActivationTest.kt | 142 ------------------ .../radio/ks3ckc/ft8af/rota/RotaClientTest.kt | 120 ++++++++++++++- .../ks3ckc/ft8af/rota/RotaPlannedTripTest.kt | 107 +++++++++++++ 9 files changed, 456 insertions(+), 264 deletions(-) delete mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaActivationTest.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaPlannedTripTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaClient.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaClient.kt index 9bd189f54..6cdf1cde3 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaClient.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaClient.kt @@ -43,6 +43,55 @@ fun isRetryableRotaFailure(error: Throwable?): Boolean = else -> false } +/** What a failed `POST /api/trips/:id/start` means for the trip in hand. */ +enum class PlanStartOutcome { + /** 409 — the row is no longer `planned`, so it already *is* the running trip. */ + ALREADY_STARTED, + + /** 404 — the announcement was cancelled on the site; create a plain trip instead. */ + PLAN_GONE, + + /** Anything else, including every ordinary network failure. */ + RETRY, +} + +/** + * Classify a failure from starting an announced trip. + * + * Both special cases are states a *rover* reaches normally rather than errors: a + * retry landing twice over a flaky link, and a plan cancelled on the site while + * the phone had no signal. Treating either as a hard failure would strand a + * drive that really happened behind a queue that never drains — so neither goes + * through [isRetryableRotaFailure], which correctly calls a 4xx fatal. + * + * [planId] empty means there was no plan to fall back from (a plain create), so + * the answer is always [PlanStartOutcome.RETRY] and the usual handling applies. + */ +fun classifyPlanStartFailure( + error: Throwable?, + planId: String, +): PlanStartOutcome { + if (planId.isEmpty() || error !is RotaHttpException) return PlanStartOutcome.RETRY + return when (error.httpCode) { + 409 -> PlanStartOutcome.ALREADY_STARTED + 404 -> PlanStartOutcome.PLAN_GONE + else -> PlanStartOutcome.RETRY + } +} + +/** + * Whether the trip left behind by [outcome] has to be asked what it already holds. + * + * One question decides it: did *this* process put the row into `active`? A trip + * we started ourselves knows exactly what it has sent, so the handshake would be + * a wasted request. An *adopted* row — [PlanStartOutcome.ALREADY_STARTED], where + * another device started the plan or a start we never saw the answer to landed — + * could hold anything, and that is the same not-knowing a trip resumed from disk + * has. [PlanStartOutcome.PLAN_GONE] creates a fresh trip and + * [PlanStartOutcome.RETRY] never got one, so neither has a server to ask. + */ +fun needsResumeHandshake(outcome: PlanStartOutcome): Boolean = outcome == PlanStartOutcome.ALREADY_STARTED + /** * Backoff before retry [attempt] (1-based): 30s, 60s, 2m, 4m, … capped at 15 * minutes. Long by HTTP standards on purpose — the failure this handles is @@ -64,10 +113,16 @@ fun rotaBackoffMs(attempt: Int): Long { * * Endpoints used: * POST /api/operators -> register a callsign, receive an API key - * POST /api/trips -> create a trip + * POST /api/trips -> create a trip; `status: "planned"` announces + * one ahead of time instead of starting it + * POST /api/trips/:id/start -> an announced trip departing (planned -> active) * POST /api/trips/:id/live -> append points + QSOs (idempotent for QSOs) * POST /api/trips/:id/complete -> finalize - * POST /api/activations -> announce a planned trip + * + * A trip is one row on the server across its whole life. Announcements used to + * be a separate resource (`/api/activations`) that a trip was matched to by + * comparing departure times; that guess is gone, and an announced trip is + * started by id. */ object RotaClient { private const val TAG = "RotaClient" @@ -168,19 +223,20 @@ object RotaClient { } /** - * The operator's own planned trips, soonest first. + * The operator's own announced trips, soonest first. * - * Read from `/api/me` rather than `/api/activations`: the public listing only - * carries what a stranger may see, and a plan the operator marked `private` - * or `followers` — the ones most likely to be a real upcoming trip — would be - * missing from exactly the list they are trying to pick from. + * Read from `/api/me` rather than the public `GET /api/trips?status=planned`: + * the public listing only carries what a stranger may see, and a plan the + * operator marked `private` or `followers` — the ones most likely to be a + * real upcoming trip — would be missing from exactly the list they are + * trying to pick from. */ - suspend fun fetchMyActivations( + suspend fun fetchMyPlannedTrips( baseUrl: String, apiKey: String, - ): Result> = + ): Result> = withContext(Dispatchers.IO) { - request("GET", "$baseUrl/api/me", apiKey, null).map { parseMyActivations(it) } + request("GET", "$baseUrl/api/me", apiKey, null).map { parseMyPlannedTrips(it) } } suspend fun completeTrip( @@ -192,11 +248,14 @@ object RotaClient { request("POST", "$baseUrl/api/trips/$tripId/complete", apiKey, "{}").map { } } - /** Announce a planned activation so followers see it before departure. */ - suspend fun createActivation( + /** + * Announce a trip so followers see it before departure — a `planned` trip, + * carrying the privacy choices it will keep when it is started. + */ + suspend fun announceTrip( baseUrl: String, apiKey: String, - title: String, + name: String, startTimeMs: Long, endTimeMs: Long? = null, detail: String? = null, @@ -207,7 +266,8 @@ object RotaClient { withContext(Dispatchers.IO) { val body = JSONObject().apply { - put("title", title.trim().take(200)) + put("status", "planned") + put("name", name.trim().take(200)) put("startTime", isoUtc(startTimeMs)) endTimeMs?.let { put("endTime", isoUtc(it)) } detail?.takeIf { it.isNotBlank() }?.let { put("detail", it.trim().take(2000)) } @@ -215,9 +275,37 @@ object RotaClient { if (modes.isNotEmpty()) put("modes", JSONArray(modes.take(20))) privacy?.takeIf { it.isNotBlank() }?.let { put("privacy", it) } }.toString() - request("POST", "$baseUrl/api/activations", apiKey, body).mapCatching { resp -> + request("POST", "$baseUrl/api/trips", apiKey, body).mapCatching { resp -> JSONObject(resp).optString("id").takeIf { it.isNotEmpty() } - ?: throw IllegalStateException("Server returned no activation id") + ?: throw IllegalStateException("Server returned no trip id") + } + } + + /** + * Start an announced trip: `planned` -> `active`, stamping the real departure. + * + * This is how a picked plan becomes the trip being driven. Creating a trip + * instead would leave the announcement sitting at `planned` forever with a + * duplicate beside it, and would drop the privacy the wizard chose — the + * plan's settings are already on the row this promotes. + * + * The server answers 409 when the trip is no longer `planned`, which for a + * retry that actually landed is success in disguise; [RotaHttpException.httpCode] + * lets the caller tell that apart from a real failure. + */ + suspend fun startPlannedTrip( + baseUrl: String, + apiKey: String, + tripId: String, + startTimeMs: Long, + ): Result = + withContext(Dispatchers.IO) { + val body = JSONObject().apply { put("startTime", isoUtc(startTimeMs)) }.toString() + request("POST", "$baseUrl/api/trips/$tripId/start", apiKey, body).map { resp -> + // The id is already known — it is the plan's. Only the share token + // is news, and an older server that doesn't send one just leaves + // the share link empty rather than failing the start. + RotaTripHandle(tripId, JSONObject(resp).optString("shareToken").takeIf { it.isNotEmpty() }) } } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaModels.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaModels.kt index 5d101f6d1..4972aa820 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaModels.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaModels.kt @@ -59,16 +59,16 @@ data class TripQso( data class RotaTripHandle(val id: String, val shareToken: String?) /** - * A trip the operator planned on roadsontheair.com — what the site's plan wizard saves. + * A trip the operator announced on roadsontheair.com — what the site's plan + * wizard saves, held server-side as a trip with `status: "planned"`. * - * The wizard writes a *scheduled activation*, not a trip: a trip only exists - * once someone actually drives it. So this is the thing to pick a trip's name - * from, and the plan's privacy choices ride along server-side when the trip - * turns out to fulfil it (see [activationMatchesNow]). + * It is the same row the trip will be driven as, not a separate record a trip + * gets matched to: picking one here and starting it by id is what makes the + * wizard's privacy choices apply to the drive. */ -data class RotaActivation( +data class RotaPlannedTrip( val id: String, - val title: String, + val name: String, val startTimeMs: Long, /** Planned finish, or null when the plan is open-ended. */ val endTimeMs: Long?, @@ -418,57 +418,30 @@ fun parseIsoUtc(value: String?): Long? { } /** - * How much slack the server allows either side of a planned window when - * deciding which activation a trip fulfils. - * - * Mirrors `MATCH_SLACK_HOURS` in the service's lib/activation-match.ts. - * Departures rarely run on time, and the app only uses this to *tell the - * operator* whether starting now will pick the plan up — the server remains the - * one that actually decides. - */ -const val ACTIVATION_MATCH_SLACK_MS = 12 * 60 * 60 * 1000L - -/** An activation with no end time is assumed to span this long, as on the server. */ -private const val ACTIVATION_DEFAULT_SPAN_MS = 24 * 60 * 60 * 1000L - -/** - * Whether a trip started at [nowMs] would fall inside [activation]'s matching - * window, and so inherit the privacy the plan wizard chose. - * - * Worth surfacing because the consequence is invisible otherwise: a trip started - * outside the window is created with the operator's *default* privacy, which for - * a plan marked `delayed` means publishing a live position that was meant to lag. - */ -fun activationMatchesNow( - activation: RotaActivation, - nowMs: Long, -): Boolean { - val windowStart = activation.startTimeMs - ACTIVATION_MATCH_SLACK_MS - val windowEnd = - (activation.endTimeMs ?: (activation.startTimeMs + ACTIVATION_DEFAULT_SPAN_MS)) + - ACTIVATION_MATCH_SLACK_MS - return nowMs in windowStart..windowEnd -} - -/** - * Pull the operator's own planned trips out of a `GET /api/me` body, soonest - * departure first. Rows missing an id, title or start time are dropped rather + * Pull the operator's own announced trips out of a `GET /api/me` body, soonest + * departure first. Rows missing an id, name or start time are dropped rather * than shown as blanks in the picker. + * + * The key is `plannedTrips` and the label is `name`. Both were `activations` + * and `title` before announcements became a trip status, and the rename is the + * kind that fails quietly here — the catch below turns a shape mismatch into an + * empty list, which the picker renders as "no upcoming trips" rather than an + * error. Worth pinning in a test for exactly that reason. */ -fun parseMyActivations(body: String?): List { +fun parseMyPlannedTrips(body: String?): List { if (body.isNullOrBlank()) return emptyList() return try { - val array = JSONObject(body).optJSONArray("activations") ?: return emptyList() + val array = JSONObject(body).optJSONArray("plannedTrips") ?: return emptyList() buildList { for (i in 0 until array.length()) { val o = array.optJSONObject(i) ?: continue val id = o.optString("id").takeIf { it.isNotEmpty() } ?: continue - val title = o.optString("title").takeIf { it.isNotEmpty() } ?: continue + val name = o.optString("name").takeIf { it.isNotEmpty() } ?: continue val start = parseIsoUtc(o.optString("startTime")) ?: continue add( - RotaActivation( + RotaPlannedTrip( id = id, - title = title, + name = name, startTimeMs = start, endTimeMs = parseIsoUtc(o.optString("endTime")), detail = o.optString("detail").takeIf { it.isNotEmpty() }, diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaSettings.kt index bd13dc7db..84ffcde75 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaSettings.kt @@ -40,6 +40,7 @@ object RotaSettings { private const val KEY_TRIP_STARTED_MS = "tripStartedMs" private const val KEY_TRIP_SHARE_TOKEN = "tripShareToken" private const val KEY_TRIP_PRIVACY = "tripPrivacy" + private const val KEY_TRIP_PLAN_ID = "tripPlanId" private const val KEY_TRIP_PENDING_CREATE = "tripPendingCreate" private const val KEY_TRIP_PENDING_COMPLETE = "tripPendingComplete" @@ -178,6 +179,19 @@ object RotaSettings { get() = readString(KEY_TRIP_PRIVACY, "") set(value) = writeString(KEY_TRIP_PRIVACY, value) + /** + * The announced trip this drive is fulfilling, when the operator picked one + * — empty for a trip that was never announced. + * + * Held rather than just its name because the id is what promotes the plan: + * the flush loop starts *that* row instead of creating a new trip, which is + * what keeps the privacy the plan wizard chose and stops an announcement + * lingering at `planned` with a duplicate beside it. + */ + var tripPlanId: String + get() = readString(KEY_TRIP_PLAN_ID, "") + set(value) = writeString(KEY_TRIP_PLAN_ID, value) + var tripStartedMs: Long get() = prefsOrNull()?.getLong(KEY_TRIP_STARTED_MS, 0L) ?: 0L set(value) { @@ -186,8 +200,8 @@ object RotaSettings { /** * True when the user started a trip out of coverage: tracking runs and the - * queue fills locally, and the flush loop keeps retrying `POST /api/trips` - * until it lands. Without this, starting a trip in a dead zone (which is + * queue fills locally, and the flush loop keeps retrying the create (or the + * start, for a picked plan) until it lands. Without this, starting a trip in a dead zone (which is * where roving starts more often than not) would simply fail. */ var tripPendingCreate: Boolean @@ -212,6 +226,7 @@ object RotaSettings { ?.remove(KEY_TRIP_NAME) ?.remove(KEY_TRIP_SHARE_TOKEN) ?.remove(KEY_TRIP_PRIVACY) + ?.remove(KEY_TRIP_PLAN_ID) ?.remove(KEY_TRIP_STARTED_MS) ?.remove(KEY_TRIP_PENDING_CREATE) ?.remove(KEY_TRIP_PENDING_COMPLETE) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaTripManager.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaTripManager.kt index fa63bf747..710e5edda 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaTripManager.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/rota/RotaTripManager.kt @@ -118,9 +118,10 @@ object RotaTripManager { private var failedAttempts = 0 /** - * Set when a trip is resumed from disk, cleared once the server has told us - * what it already holds. Only a *resumed* trip needs asking: a trip this - * process started knows exactly what it has sent. + * Set when the trip in hand is one this process did not start — resumed from + * disk, or adopted after a 409 — and cleared once the server has told us what + * it already holds. A trip this process started knows exactly what it has + * sent and skips the request. See [needsResumeHandshake]. */ @Volatile private var resumeHandshakePending = false @@ -214,6 +215,7 @@ object RotaTripManager { name: String, privacy: String?, notes: String? = null, + planId: String? = null, ) { if (RotaSettings.hasActiveTrip) { log("startTrip ignored — a trip is already running") @@ -223,6 +225,7 @@ object RotaTripManager { RotaSettings.tripName = name.trim().ifEmpty { defaultTripName(startedMs) } RotaSettings.tripStartedMs = startedMs RotaSettings.tripPrivacy = privacy.orEmpty() + RotaSettings.tripPlanId = planId.orEmpty() RotaSettings.tripPendingCreate = true RotaSettings.tripPendingComplete = false RotaSettings.tripId = "" @@ -452,17 +455,32 @@ object RotaTripManager { _state.value = _state.value.copy(uploading = true) // 1. The trip may not exist server-side yet (started out of coverage). + // + // An announced trip already exists — it is sitting at `planned` — + // so it is *started*, not created. Creating would leave the + // announcement stranded with a duplicate beside it and lose the + // privacy the plan wizard chose. if (RotaSettings.tripPendingCreate) { - val created = - RotaClient.createTrip( - baseUrl = baseUrl, - apiKey = apiKey, - name = RotaSettings.tripName, - startTimeMs = RotaSettings.tripStartedMs, - notes = pendingNotes, - privacy = RotaSettings.tripPrivacy.takeIf { it.isNotBlank() }, - ) - created.fold( + val planId = RotaSettings.tripPlanId + val landed = + if (planId.isNotEmpty()) { + RotaClient.startPlannedTrip( + baseUrl = baseUrl, + apiKey = apiKey, + tripId = planId, + startTimeMs = RotaSettings.tripStartedMs, + ) + } else { + RotaClient.createTrip( + baseUrl = baseUrl, + apiKey = apiKey, + name = RotaSettings.tripName, + startTimeMs = RotaSettings.tripStartedMs, + notes = pendingNotes, + privacy = RotaSettings.tripPrivacy.takeIf { it.isNotBlank() }, + ) + } + landed.fold( onSuccess = { handle -> RotaSettings.tripId = handle.id RotaSettings.tripShareToken = handle.shareToken.orEmpty() @@ -474,11 +492,38 @@ object RotaTripManager { shareToken = handle.shareToken.orEmpty(), pendingCreate = false, ) - log("trip created id=${handle.id}") + log(if (planId.isNotEmpty()) "plan started id=${handle.id}" else "trip created id=${handle.id}") }, onFailure = { e -> - failed(e, "create") - return@withLock + when (classifyPlanStartFailure(e, planId)) { + // 409: the plan is no longer `planned`, so a previous + // attempt landed (or another device started it). The + // row is the trip; adopt it, and arm the resume + // handshake below — an adopted row is one this + // process did not start, so what it already holds is + // exactly as unknown as a trip resumed from disk. + PlanStartOutcome.ALREADY_STARTED -> { + RotaSettings.tripId = planId + RotaSettings.tripPendingCreate = false + pendingNotes = null + resumeHandshakePending = needsResumeHandshake(PlanStartOutcome.ALREADY_STARTED) + _state.value = _state.value.copy(tripId = planId, pendingCreate = false) + log("plan already started id=$planId — adopting it") + } + // 404: the plan was cancelled on the site while the + // phone was out of coverage. Fall back to an ordinary + // trip rather than stranding a drive that happened. + PlanStartOutcome.PLAN_GONE -> { + RotaSettings.tripPlanId = "" + log("plan $planId is gone — creating a plain trip on the next flush") + requestFlush("plan-gone") + return@withLock + } + PlanStartOutcome.RETRY -> { + failed(e, if (planId.isNotEmpty()) "start" else "create") + return@withLock + } + } }, ) } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rota/RoadTripScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rota/RoadTripScreen.kt index a042e760e..8fddd6bf5 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rota/RoadTripScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/rota/RoadTripScreen.kt @@ -42,9 +42,8 @@ import com.k1af.ft8af.R import kotlinx.coroutines.launch import radio.ks3ckc.ft8af.location.hasLocationPermission import radio.ks3ckc.ft8af.rota.BeaconReason -import radio.ks3ckc.ft8af.rota.RotaActivation +import radio.ks3ckc.ft8af.rota.RotaPlannedTrip import radio.ks3ckc.ft8af.rota.RotaHttpException -import radio.ks3ckc.ft8af.rota.activationMatchesNow import radio.ks3ckc.ft8af.rota.ROTA_CQ_MODIFIER import radio.ks3ckc.ft8af.rota.RotaClient import radio.ks3ckc.ft8af.rota.RotaSettings @@ -81,6 +80,9 @@ fun RoadTripScreen(onBack: () -> Unit) { var apiKey by remember { mutableStateOf(RotaSettings.apiKey) } var privacy by remember { mutableStateOf(RotaSettings.defaultPrivacy) } var tripName by remember { mutableStateOf("") } + // The announced trip this drive fulfils, when one was picked. Held so Start + // promotes that row rather than creating a second trip beside it. + var tripPlanId by remember { mutableStateOf("") } var busy by remember { mutableStateOf(false) } var message by remember { mutableStateOf(null) } @@ -90,7 +92,7 @@ fun RoadTripScreen(onBack: () -> Unit) { var showKeyDialog by remember { mutableStateOf(false) } var showTripNameDialog by remember { mutableStateOf(false) } var showPlanPicker by remember { mutableStateOf(false) } - var plans by remember { mutableStateOf>(emptyList()) } + var plans by remember { mutableStateOf>(emptyList()) } var plansLoading by remember { mutableStateOf(false) } var plansError by remember { mutableStateOf(null) } var showAnnounceDialog by remember { mutableStateOf(false) } @@ -166,10 +168,10 @@ fun RoadTripScreen(onBack: () -> Unit) { plans = plans, loading = plansLoading, error = plansError, - nowMs = System.currentTimeMillis(), onDismiss = { showPlanPicker = false }, onPick = { picked -> - tripName = picked + tripName = picked.name + tripPlanId = picked.id showPlanPicker = false }, onTypeName = { @@ -187,6 +189,10 @@ fun RoadTripScreen(onBack: () -> Unit) { onDismiss = { showTripNameDialog = false }, onSave = { tripName = it.trim() + // A hand-typed name is a different trip from the plan that was + // picked; keeping the id would start that announcement under a + // name its followers never saw. + tripPlanId = "" showTripNameDialog = false }, ) @@ -201,10 +207,10 @@ fun RoadTripScreen(onBack: () -> Unit) { scope.launch { val start = System.currentTimeMillis() + hoursFromNow * 3_600_000L val result = - RotaClient.createActivation( + RotaClient.announceTrip( baseUrl = RotaSettings.baseUrl, apiKey = RotaSettings.apiKey, - title = title, + name = title, startTimeMs = start, privacy = privacy.takeIf { it.isNotBlank() }, ) @@ -334,7 +340,7 @@ fun RoadTripScreen(onBack: () -> Unit) { plansLoading = true plansError = null scope.launch { - RotaClient.fetchMyActivations( + RotaClient.fetchMyPlannedTrips( RotaSettings.baseUrl, RotaSettings.apiKey, ).fold( @@ -381,6 +387,7 @@ fun RoadTripScreen(onBack: () -> Unit) { RotaTripManager.startTrip( name = tripName, privacy = privacy.takeIf { it.isNotBlank() }, + planId = tripPlanId.takeIf { it.isNotEmpty() }, ) message = null } @@ -546,25 +553,24 @@ private fun NoticeText(text: String) { } /** - * Pick the trip's name from the plans already saved on roadsontheair.com, or type one. + * Pick the trip to drive from the ones already announced on roadsontheair.com, + * or type a name for an unannounced one. * - * The plans are *scheduled activations* — what the site's plan wizard writes — - * because a trip only exists once someone drives it. Choosing one here does not - * bind anything: the server decides which plan a trip fulfils by comparing start - * times (±12 h), so the value of picking is that the name matches the plan and - * the operator can see, before setting off, whether starting now will inherit - * the privacy they chose in the wizard. A plan outside that window is still - * listed — driving early is normal — just marked so the inheritance isn't a - * surprise. + * Picking binds: the announced trip *is* the trip, held server-side at + * `planned`, and Start promotes that row. So its privacy — the delay, the route + * trim, the replay lock chosen in the wizard — applies to this drive by + * construction. It used to be a name-only convenience, with the server guessing + * which plan a new trip fulfilled by comparing departure times within ±12 h; + * driving outside that window silently fell back to the account default. There + * is nothing left to warn about, so the rows just show when each one departs. */ @Composable private fun TripPlanPickerDialog( - plans: List, + plans: List, loading: Boolean, error: String?, - nowMs: Long, onDismiss: () -> Unit, - onPick: (String) -> Unit, + onPick: (RotaPlannedTrip) -> Unit, onTypeName: () -> Unit, ) { androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) { @@ -621,27 +627,22 @@ private fun TripPlanPickerDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { plans.forEach { plan -> - val matches = activationMatchesNow(plan, nowMs) Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(10.dp)) - .clickable { onPick(plan.title) } + .clickable { onPick(plan) } .padding(vertical = 10.dp, horizontal = 12.dp), ) { - Text(text = plan.title, color = TextPrimary, fontSize = 15.sp) + Text(text = plan.name, color = TextPrimary, fontSize = 15.sp) Text( text = - if (matches) { - stringResource(R.string.rota_plan_matches_now) - } else { - stringResource( - R.string.rota_plan_starts, - formatPlanStart(plan.startTimeMs), - ) - }, - color = if (matches) Accent else TextFaint, + stringResource( + R.string.rota_plan_starts, + formatPlanStart(plan.startTimeMs), + ), + color = TextFaint, fontSize = 12.sp, ) } diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index d5966271f..850fe44bd 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -919,8 +919,7 @@ No plans saved on roadsontheair.com yet. Plan one on the site, or type a name. Type a name Couldn\'t load your plans: %1$s - Starting now uses this plan\'s privacy settings - Starts %1$s — outside the window, so its privacy won\'t apply + Starts %1$s Calling trip start interval diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaActivationTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaActivationTest.kt deleted file mode 100644 index af6f7b933..000000000 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaActivationTest.kt +++ /dev/null @@ -1,142 +0,0 @@ -package radio.ks3ckc.ft8af.rota - -import com.google.common.truth.Truth.assertThat -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -/** - * Reading the operator's saved plans off `/api/me`, and deciding whether - * starting a trip now would actually pick one up. - * - * The window check mirrors `MATCH_SLACK_HOURS` in the service's - * lib/activation-match.ts. It is advisory — the server still decides — but the - * consequence of the app disagreeing is a silent one: a plan marked `delayed` - * whose privacy doesn't apply publishes a live position that was meant to lag, - * and nothing on screen would have said so. - * - * Robolectric because org.json is an Android stub on the plain JVM classpath. - */ -@RunWith(RobolectricTestRunner::class) -class RotaActivationTest { - // 2026-08-01T22:00:00Z - private val start = 1_785_621_600_000L - private val hour = 3_600_000L - - private fun plan( - startMs: Long = start, - endMs: Long? = start + 8 * hour, - ) = RotaActivation("id", "Shakedown", startMs, endMs, null) - - @Test - fun `parses the plans an api-me body carries, soonest first`() { - val body = - """ - { - "operator": {"callsign": "K1AF"}, - "activations": [ - {"id":"b","title":"DEFCON To Kansas City","startTime":"2026-08-10T11:00:00.000Z", - "endTime":"2026-08-11T11:00:00.000Z","detail":"return leg"}, - {"id":"a","title":"Shakedown test drive","startTime":"2026-08-01T22:00:00.000Z", - "endTime":"2026-08-02T06:00:00.000Z"} - ] - } - """.trimIndent() - - val plans = parseMyActivations(body) - assertThat(plans.map { it.title }) - .containsExactly("Shakedown test drive", "DEFCON To Kansas City") - .inOrder() - assertThat(plans[0].startTimeMs).isEqualTo(start) - assertThat(plans[1].detail).isEqualTo("return leg") - } - - @Test - fun `an open-ended plan parses with a null end`() { - val body = """{"activations":[{"id":"a","title":"Open","startTime":"2026-08-01T22:00:00.000Z"}]}""" - assertThat(parseMyActivations(body).single().endTimeMs).isNull() - } - - @Test - fun `rows missing an id, title or start are dropped rather than shown blank`() { - val body = - """ - {"activations":[ - {"title":"No id","startTime":"2026-08-01T22:00:00.000Z"}, - {"id":"b","startTime":"2026-08-01T22:00:00.000Z"}, - {"id":"c","title":"No start"}, - {"id":"d","title":"Good","startTime":"2026-08-01T22:00:00.000Z"} - ]} - """.trimIndent() - assertThat(parseMyActivations(body).map { it.title }).containsExactly("Good") - } - - @Test - fun `a missing or unusable body yields no plans rather than throwing`() { - assertThat(parseMyActivations(null)).isEmpty() - assertThat(parseMyActivations("")).isEmpty() - assertThat(parseMyActivations("not json")).isEmpty() - assertThat(parseMyActivations("""{"operator":{}}""")).isEmpty() - } - - @Test - fun `iso timestamps parse as UTC, and junk does not`() { - assertThat(parseIsoUtc("2026-08-01T22:00:00.000Z")).isEqualTo(start) - assertThat(parseIsoUtc(null)).isNull() - assertThat(parseIsoUtc("")).isNull() - // A local-time string would silently place a plan hours away. - assertThat(parseIsoUtc("2026-08-01 22:00:00")).isNull() - assertThat(parseIsoUtc("tomorrow")).isNull() - } - - @Test - fun `a trip started inside the planned window matches`() { - assertThat(activationMatchesNow(plan(), start)).isTrue() - assertThat(activationMatchesNow(plan(), start + 4 * hour)).isTrue() - } - - @Test - fun `the twelve-hour slack either side matches, mirroring the server`() { - // Departures rarely run on time, which is why the server allows this at all. - assertThat(activationMatchesNow(plan(), start - 11 * hour)).isTrue() - assertThat(activationMatchesNow(plan(), start + 8 * hour + 11 * hour)).isTrue() - } - - @Test - fun `the slack boundary itself is inclusive, to the millisecond`() { - // The server's check is `t >= windowStart && t <= windowEnd`, so exactly - // twelve hours out still matches. Asserting only an hour inside the edge - // (as the test above does) would pass just as happily against an eleven- - // or thirteen-hour rule — the boundary is the only place the constant is - // actually pinned. - val windowStart = start - ACTIVATION_MATCH_SLACK_MS - val windowEnd = start + 8 * hour + ACTIVATION_MATCH_SLACK_MS - - assertThat(activationMatchesNow(plan(), windowStart)).isTrue() - assertThat(activationMatchesNow(plan(), windowStart - 1)).isFalse() - assertThat(activationMatchesNow(plan(), windowEnd)).isTrue() - assertThat(activationMatchesNow(plan(), windowEnd + 1)).isFalse() - } - - @Test - fun `the slack is twelve hours, not some other span`() { - assertThat(ACTIVATION_MATCH_SLACK_MS).isEqualTo(12 * 60 * 60 * 1000L) - } - - @Test - fun `outside the slack does not match`() { - assertThat(activationMatchesNow(plan(), start - 13 * hour)).isFalse() - assertThat(activationMatchesNow(plan(), start + 8 * hour + 13 * hour)).isFalse() - } - - @Test - fun `an open-ended plan is assumed to span a day, as on the server`() { - val open = plan(endMs = null) - assertThat(activationMatchesNow(open, start + 20 * hour)).isTrue() - // 24 h assumed span + 12 h slack = 36 h, and the edge is inclusive. Pinned at - // the boundary rather than an hour past it, so this can't keep passing if the - // assumed span drifts. - assertThat(activationMatchesNow(open, start + 36 * hour)).isTrue() - assertThat(activationMatchesNow(open, start + 36 * hour + 1)).isFalse() - } -} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaClientTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaClientTest.kt index 26bd31541..ead692ac1 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaClientTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaClientTest.kt @@ -134,25 +134,131 @@ class RotaClientTest { } @Test - fun `createActivation posts the announcement`() = + fun `announceTrip posts a planned trip to the trips endpoint`() = runBlocking { - server.enqueue(MockResponse().setResponseCode(201).setBody("""{"id":"act-1"}""")) + server.enqueue(MockResponse().setResponseCode(201).setBody("""{"id":"plan-1"}""")) val id = - RotaClient.createActivation( + RotaClient.announceTrip( baseUrl = baseUrl, apiKey = "k", - title = "I-70 westbound", + name = "I-70 westbound", startTimeMs = 1_753_970_709_000L, bands = listOf("20m", "40m"), modes = listOf("FT8"), ).getOrNull() - assertThat(id).isEqualTo("act-1") - val body = JSONObject(server.takeRequest().body.readUtf8()) - assertThat(body.getString("title")).isEqualTo("I-70 westbound") + assertThat(id).isEqualTo("plan-1") + val request = server.takeRequest() + // /api/activations is gone; an announcement is a trip with a status. + assertThat(request.path).isEqualTo("/api/trips") + val body = JSONObject(request.body.readUtf8()) + assertThat(body.getString("status")).isEqualTo("planned") + assertThat(body.getString("name")).isEqualTo("I-70 westbound") assertThat(body.getJSONArray("bands").length()).isEqualTo(2) } + @Test + fun `startPlannedTrip promotes the plan by id and keeps its share token`() = + runBlocking { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{"id":"plan-1","status":"active","startTime":"2026-08-02T14:00:00.000Z","shareToken":"tok-9"}""", + ), + ) + val handle = + RotaClient.startPlannedTrip( + baseUrl = baseUrl, + apiKey = "k", + tripId = "plan-1", + startTimeMs = 1_753_970_709_000L, + ).getOrNull() + + assertThat(handle?.id).isEqualTo("plan-1") + assertThat(handle?.shareToken).isEqualTo("tok-9") + val request = server.takeRequest() + assertThat(request.path).isEqualTo("/api/trips/plan-1/start") + assertThat(JSONObject(request.body.readUtf8()).getString("startTime")) + .isEqualTo("2025-07-31T14:05:09.000Z") + } + + @Test + fun `startPlannedTrip survives a server that sends no share token`() = + runBlocking { + // The field was added after the endpoint shipped; a missing one must + // leave the share link empty rather than fail the start and strand + // the trip in "pending create" forever. + server.enqueue(MockResponse().setResponseCode(200).setBody("""{"id":"plan-1","status":"active"}""")) + val handle = + RotaClient.startPlannedTrip(baseUrl, "k", "plan-1", 1_753_970_709_000L).getOrNull() + + assertThat(handle?.id).isEqualTo("plan-1") + assertThat(handle?.shareToken).isNull() + } + + @Test + fun `a 409 from start comes back as an http failure the caller can classify`() = + runBlocking { + // The manager turns this into "already started, adopt the row" rather + // than an error — but only if the code survives as RotaHttpException. + server.enqueue(MockResponse().setResponseCode(409).setBody("""{"error":"Trip is already active"}""")) + val error = + RotaClient.startPlannedTrip(baseUrl, "k", "plan-1", 1_753_970_709_000L).exceptionOrNull() + + assertThat(error).isInstanceOf(RotaHttpException::class.java) + assertThat((error as RotaHttpException).httpCode).isEqualTo(409) + assertThat(error.serverMessage).isEqualTo("Trip is already active") + } + + @Test + fun `a 409 start means the plan is already the running trip, not a failure`() { + // The rover retried over a flaky link and the first attempt actually + // landed. Surfacing this as an error would leave a trip stuck "pending + // create" with a queue that never drains, so the manager adopts the row. + val outcome = classifyPlanStartFailure(RotaHttpException(409, ""), "plan-1") + assertThat(outcome).isEqualTo(PlanStartOutcome.ALREADY_STARTED) + } + + @Test + fun `a 404 start means the plan was cancelled, so fall back to a plain trip`() { + // Cancelled on the site while the phone had no signal. The drive still + // happened; creating an ordinary trip is what keeps its route. + val outcome = classifyPlanStartFailure(RotaHttpException(404, ""), "plan-1") + assertThat(outcome).isEqualTo(PlanStartOutcome.PLAN_GONE) + } + + @Test + fun `other start failures retry, and a plain create never falls back`() { + assertThat(classifyPlanStartFailure(RotaHttpException(503, ""), "plan-1")) + .isEqualTo(PlanStartOutcome.RETRY) + assertThat(classifyPlanStartFailure(IOException("no route to host"), "plan-1")) + .isEqualTo(PlanStartOutcome.RETRY) + // A 401 is fatal, but that is `failed()`'s call via isRetryableRotaFailure — + // what matters here is that it is not mistaken for an adopt-or-recreate. + assertThat(classifyPlanStartFailure(RotaHttpException(401, ""), "plan-1")) + .isEqualTo(PlanStartOutcome.RETRY) + // No plan id: there was no promotion to fall back from. + assertThat(classifyPlanStartFailure(RotaHttpException(409, ""), "")) + .isEqualTo(PlanStartOutcome.RETRY) + assertThat(classifyPlanStartFailure(RotaHttpException(404, ""), "")) + .isEqualTo(PlanStartOutcome.RETRY) + } + + @Test + fun `an adopted trip is asked what it holds, a trip we started is not`() { + // The 409 branch adopts a row this process did not start — another + // device's, or a start whose answer we never saw — so its contents are + // as unknown as a trip resumed from disk, and the queue must not be + // re-sent blind. The other outcomes have no server row to ask about: + // PLAN_GONE creates a fresh trip, RETRY never got one. + assertThat(needsResumeHandshake(PlanStartOutcome.ALREADY_STARTED)).isTrue() + assertThat(needsResumeHandshake(PlanStartOutcome.PLAN_GONE)).isFalse() + assertThat(needsResumeHandshake(PlanStartOutcome.RETRY)).isFalse() + // Pin the set: a new outcome must decide this deliberately rather than + // inherit "no handshake" by being added to the enum. + assertThat(PlanStartOutcome.entries.filter { needsResumeHandshake(it) }) + .containsExactly(PlanStartOutcome.ALREADY_STARTED) + } + @Test fun `a server error is retryable, a rejected request is not`() { assertThat(isRetryableRotaFailure(RotaHttpException(503, ""))).isTrue() diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaPlannedTripTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaPlannedTripTest.kt new file mode 100644 index 000000000..00fa95da0 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaPlannedTripTest.kt @@ -0,0 +1,107 @@ +package radio.ks3ckc.ft8af.rota + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Reading the operator's announced trips off `GET /api/me`. + * + * This parser fails *quietly*: a shape it doesn't recognise comes back as an + * empty list, which the picker renders as "no upcoming trips" rather than an + * error. That already happened once — announcements moved from a + * `scheduled_activations` table into `trips` with a status, and the response + * key went `activations` -> `plannedTrips` with `title` -> `name` — so the key + * and field names are pinned here deliberately. + * + * Robolectric because org.json is an Android stub on the plain JVM classpath. + */ +@RunWith(RobolectricTestRunner::class) +class RotaPlannedTripTest { + // 2026-08-01T22:00:00Z + private val start = 1_785_621_600_000L + + @Test + fun `parses the announced trips an api-me body carries, soonest first`() { + val body = + """ + { + "operator": {"callsign": "K1AF"}, + "plannedTrips": [ + {"id":"b","name":"DEFCON To Kansas City","startTime":"2026-08-10T11:00:00.000Z", + "endTime":"2026-08-11T11:00:00.000Z","detail":"return leg"}, + {"id":"a","name":"Shakedown test drive","startTime":"2026-08-01T22:00:00.000Z", + "endTime":"2026-08-02T06:00:00.000Z"} + ] + } + """.trimIndent() + + val plans = parseMyPlannedTrips(body) + assertThat(plans.map { it.name }) + .containsExactly("Shakedown test drive", "DEFCON To Kansas City") + .inOrder() + assertThat(plans[0].id).isEqualTo("a") + assertThat(plans[0].startTimeMs).isEqualTo(start) + assertThat(plans[1].detail).isEqualTo("return leg") + } + + @Test + fun `the pre-merge activations shape yields nothing, not blank rows`() { + // The body the service used to send. Asserting empty is not endorsing it — + // it documents that this exact mismatch produces a picker that looks + // merely empty, which is why the keys above are pinned. + val legacy = + """ + {"activations":[{"id":"a","title":"Shakedown","startTime":"2026-08-01T22:00:00.000Z"}]} + """.trimIndent() + assertThat(parseMyPlannedTrips(legacy)).isEmpty() + } + + @Test + fun `an id is kept, because starting the plan is what promotes that row`() { + // The id is the whole point of picking: Start posts to + // /api/trips//start. A parser that dropped it would silently create a + // duplicate trip beside the announcement instead. + val body = """{"plannedTrips":[{"id":"plan-7","name":"Open","startTime":"2026-08-01T22:00:00.000Z"}]}""" + assertThat(parseMyPlannedTrips(body).single().id).isEqualTo("plan-7") + } + + @Test + fun `an open-ended plan parses with a null end`() { + val body = """{"plannedTrips":[{"id":"a","name":"Open","startTime":"2026-08-01T22:00:00.000Z"}]}""" + assertThat(parseMyPlannedTrips(body).single().endTimeMs).isNull() + } + + @Test + fun `rows missing an id, name or start are dropped rather than shown blank`() { + val body = + """ + {"plannedTrips":[ + {"name":"No id","startTime":"2026-08-01T22:00:00.000Z"}, + {"id":"b","startTime":"2026-08-01T22:00:00.000Z"}, + {"id":"c","name":"No start"}, + {"id":"d","name":"Good","startTime":"2026-08-01T22:00:00.000Z"} + ]} + """.trimIndent() + assertThat(parseMyPlannedTrips(body).map { it.name }).containsExactly("Good") + } + + @Test + fun `a missing or unusable body yields no plans rather than throwing`() { + assertThat(parseMyPlannedTrips(null)).isEmpty() + assertThat(parseMyPlannedTrips("")).isEmpty() + assertThat(parseMyPlannedTrips("not json")).isEmpty() + assertThat(parseMyPlannedTrips("""{"operator":{}}""")).isEmpty() + } + + @Test + fun `iso timestamps parse as UTC, and junk does not`() { + assertThat(parseIsoUtc("2026-08-01T22:00:00.000Z")).isEqualTo(start) + assertThat(parseIsoUtc(null)).isNull() + assertThat(parseIsoUtc("")).isNull() + // A local-time string would silently place a plan hours away. + assertThat(parseIsoUtc("2026-08-01 22:00:00")).isNull() + assertThat(parseIsoUtc("tomorrow")).isNull() + } +} From 3a9753fac9344ac46263297b416ce251fc4b30ca Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sun, 2 Aug 2026 08:22:57 -0500 Subject: [PATCH 108/113] Show real VUCC grid-square progress on the Awards tab (was hardcoded 0) (#668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Show real VUCC grid-square progress on the Awards tab (was hardcoded 0) The Awards tab's VUCC (grid squares) card was hardcoded to current = 0, so operators chasing the grid-square award always saw "0 / 100" no matter how many unique grids they had logged. The Stats tab already computed the real count; the Awards tab just never received it. Extract the grid-square counter into a pure, testable internal gridSquaresWorked(grids: List) (upper-casing with Locale.ROOT so squares de-dupe correctly under a Turkish locale), surface the count through LogbookStats.gridSquares, and use it for both the Stats-tab bar and the Awards-tab card so the two views agree. Co-Authored-By: Claude Opus 4.8 (1M context) * Read the VUCC bar from stats, not a second computation (PR #668) The Stats tab's four award bars all read stats.*, except the VUCC one, which recomputed gridSquaresWorked(records) inline. Both derive from the same loaded list, so the number matched — but `records` is assigned as soon as the query returns, while `stats` is only built after the DXCC, zone, continent and state lookups finish. In that window the VUCC bar showed a real count beside four bars still reading their defaults. Reading stats.gridSquares makes the row update as a unit, drops a full pass over the log on every recomposition, and leaves one place computing the value. Also fixes the grammar of the test file's header comment. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../ks3ckc/ft8af/ui/logbook/LogbookScreen.kt | 33 +++++++--- .../ft8af/ui/logbook/GridSquaresWorkedTest.kt | 61 +++++++++++++++++++ 2 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/GridSquaresWorkedTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt index 27dbf1d43..2b03a628d 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt @@ -138,6 +138,7 @@ private data class LogbookStats( val dxccEntities: Int = 0, val cqZones: Int = 0, val ituZones: Int = 0, + val gridSquares: Int = 0, val bandCounts: List> = emptyList(), // Raw continent codes worked (e.g. "NA", "EU"), for the WAC award. Reduced // to award progress by [workedAllContinents]; empty when nothing resolves. @@ -278,11 +279,16 @@ fun LogbookScreen(mainViewModel: MainViewModel) { UsStateLookup.stateFromGrid(appContext, it) } + // VUCC grid squares — unique 4-char Maidenhead squares across all + // logged QSOs (computed from the already-loaded records). + val gridSquareCount = gridSquaresWorked(loaded.map { it.grid }) + stats = LogbookStats( totalQsos = totalQsos, dxccEntities = dxccCount, cqZones = cqCount, ituZones = ituCount, + gridSquares = gridSquareCount, bandCounts = bandCounts, continentCodes = continentCodes, statesWorked = worked.size, @@ -724,7 +730,7 @@ private fun StatsTab(stats: LogbookStats, records: List) { ) AwardProgressBar( label = stringResource(R.string.log_award_vucc_grid_squares), - current = gridSquaresWorked(records), + current = stats.gridSquares, total = 100, gradientColors = listOf(StatusNew, Band12m), progress = chartProgress, @@ -1263,13 +1269,26 @@ private fun DrawScope.drawSparkline( } // --------------------------------------------------------------------------- -// Helper: count unique grid squares worked +// Helper: count unique grid squares worked (VUCC) // --------------------------------------------------------------------------- -private fun gridSquaresWorked(records: List): Int = - records.mapNotNull { record -> - val grid = record.grid - if (!grid.isNullOrBlank() && grid.length >= 4) grid.substring(0, 4).uppercase() else null +/** + * The number of distinct Maidenhead grid squares worked — the VUCC metric shown + * on both the Stats-tab progress bar and the Awards-tab card. A square is the + * first four grid characters (e.g. "FN31"); longer grids are truncated to their + * square and blank/partial grids are ignored. + * + * Locale.ROOT: grid letters are ASCII A..R, so upper-casing must be + * locale-insensitive — a default-locale uppercase() would map "i" to "İ" under a + * Turkish locale, counting "io91" and "IO91" as two squares instead of one. + */ +internal fun gridSquaresWorked(grids: List): Int = + grids.mapNotNull { grid -> + if (!grid.isNullOrBlank() && grid.length >= 4) { + grid.substring(0, 4).uppercase(Locale.ROOT) + } else { + null + } }.distinct().size // --------------------------------------------------------------------------- @@ -1873,7 +1892,7 @@ private fun AwardsTab(stats: LogbookStats) { AwardProgress( name = vuccName, description = vuccDesc, - current = 0, // Would need per-band grid counting + current = stats.gridSquares, total = 100, color = Band12m, ), diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/GridSquaresWorkedTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/GridSquaresWorkedTest.kt new file mode 100644 index 000000000..0ff7a7e9e --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/GridSquaresWorkedTest.kt @@ -0,0 +1,61 @@ +package radio.ks3ckc.ft8af.ui.logbook + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.util.Locale + +/** + * Unit tests for [gridSquaresWorked], the VUCC (grid-square) counter that backs + * both the Stats-tab "VUCC Grid Squares" progress bar and the Awards-tab VUCC + * card. A grid square is the first four Maidenhead characters (e.g. "FN31"); + * VUCC counts *unique* squares worked, so the helper de-dupes and ignores + * partial/blank grids. All plain-JVM (the helper takes Strings). + * + * The regression these tests guard against: the Awards-tab VUCC card used to be + * hardcoded to 0, so grid chasers saw "0 / 100" no matter how many squares they + * had worked. + */ +class GridSquaresWorkedTest { + + @Test + fun countsUniqueFourCharSquares() { + // FN31 and FN42 are different squares within the same field; both count. + assertThat(gridSquaresWorked(listOf("FN31", "FN42", "JO22"))).isEqualTo(3) + } + + @Test + fun dedupesRepeatedSquares() { + // Same square worked three times (extra chars truncated to 4) counts once. + assertThat(gridSquaresWorked(listOf("FN31", "FN31pr", "fn31"))).isEqualTo(1) + } + + @Test + fun skipsNullBlankAndTooShortGrids() { + assertThat(gridSquaresWorked(listOf(null, "", " ", "FN", "FN3"))).isEqualTo(0) + } + + @Test + fun truncatesSixCharGridToItsSquare() { + // FN31pr and FN31aa share the FN31 square. + assertThat(gridSquaresWorked(listOf("FN31pr", "FN31aa"))).isEqualTo(1) + } + + @Test + fun mixedValidAndInvalidGrids() { + assertThat(gridSquaresWorked(listOf("FN31", null, "bad", "JO22", "JO22"))).isEqualTo(2) + } + + @Test + fun upperCasesLocaleInsensitively() { + // Under a Turkish locale a default-locale uppercase() maps "i" to the + // dotted capital "İ", so "io91" and "IO91" would count as two squares. + // Locale.ROOT keeps both "IO91", so they de-dupe to one. + val previous = Locale.getDefault() + try { + Locale.setDefault(Locale("tr", "TR")) + assertThat(gridSquaresWorked(listOf("io91wm", "IO91WM"))).isEqualTo(1) + } finally { + Locale.setDefault(previous) + } + } +} From c438dad3d1fc6a5541665b17be80b7f9f15dea76 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sun, 2 Aug 2026 08:23:27 -0500 Subject: [PATCH 109/113] Report a POTA share result on the main thread, not the IO worker (#718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing a POTA activation that has no QSOs crashed the app. The exporter runs on Dispatchers.IO, and the empty-documents exit reported failure by calling the caller's callback right there on the worker. The POTA screen's callback shows a Toast, and Toast.makeText needs a Looper, so it threw "Can't toast on a thread that has not called Looper.prepare()". The empty case is just the most reachable of four exits: the missing external-files-dir and the catch-all both did the same, and only the null-database early return — which never leaves the caller's thread — was safe. So the fix belongs in the exporter, not at the one Toast: a caller cannot see which thread its callback arrives on, and fixing the symptom would leave the next caller to rediscover this. Every callback now goes through deliverOnMain, which runs inline when already on the main thread and posts to the main Looper otherwise. That keeps the null-database path synchronous, as it was. startActivity stays on the IO thread — FLAG_ACTIVITY_NEW_TASK makes that legal, and only the callback had a main-thread requirement. Fixes #700 Co-authored-by: Claude Opus 5 (1M context) --- .../ks3ckc/ft8af/ui/pota/PotaAdifExporter.kt | 47 ++++++++++-- .../ui/pota/PotaAdifExporterThreadingTest.kt | 75 +++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/pota/PotaAdifExporterThreadingTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/pota/PotaAdifExporter.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/pota/PotaAdifExporter.kt index 989d2da49..42357cd1e 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/pota/PotaAdifExporter.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/pota/PotaAdifExporter.kt @@ -3,6 +3,8 @@ package radio.ks3ckc.ft8af.ui.pota import android.content.Context import android.content.Intent import android.database.sqlite.SQLiteDatabase +import android.os.Handler +import android.os.Looper import androidx.core.content.FileProvider import com.k1af.ft8af.MainViewModel import com.k1af.ft8af.log.AdifFormat @@ -140,6 +142,39 @@ object PotaAdifExporter { } } + /** + * Deliver [result] to [onResult] on the main thread, immediately when the + * caller is already there and via the main [Looper] otherwise. + * + * Every callback out of [shareActivationAdif] goes through here, because the + * body that produces them runs on [Dispatchers.IO] and callers legitimately + * touch UI in the callback — the existing one shows a Toast, and + * `Toast.makeText` throws "Can't toast on a thread that has not called + * Looper.prepare()" off the main thread. Issue #700 was exactly that: sharing + * an activation with no QSOs takes the empty-documents exit, which reported + * failure from an IO worker and crashed the app. + * + * Making the guarantee the *exporter's* rather than each caller's is the + * point. A caller cannot see which thread the callback arrives on, so a fix + * at the one Toast would leave the next caller to rediscover this. + */ + @androidx.annotation.VisibleForTesting + internal fun deliverOnMain( + result: Boolean, + onResult: (Boolean) -> Unit, + ) { + if (Looper.myLooper() == Looper.getMainLooper()) { + onResult(result) + } else { + Handler(Looper.getMainLooper()).post { onResult(result) } + } + } + + /** + * Build this activation's ADIF documents and hand them to the system share + * sheet. [onResult] reports whether anything was shared, and is always + * invoked on the main thread — see [deliverOnMain]. + */ fun shareActivationAdif( context: Context, mainViewModel: MainViewModel, @@ -147,7 +182,7 @@ object PotaAdifExporter { onResult: (Boolean) -> Unit, ) { val db = mainViewModel.databaseOpr?.db ?: run { - onResult(false) + deliverOnMain(false, onResult) return } scope.launch { @@ -155,11 +190,11 @@ object PotaAdifExporter { val docs = buildActivationAdif(db, activation) if (docs.isEmpty()) { // No QSOs matched this activation — nothing to share. - onResult(false) + deliverOnMain(false, onResult) return@launch } val dir = context.getExternalFilesDir(null) ?: run { - onResult(false) + deliverOnMain(false, onResult) return@launch } val parks = activation.parkRefs @@ -190,11 +225,13 @@ object PotaAdifExporter { val chooser = Intent.createChooser(send, "Share POTA ADIF").apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + // Legal from a background thread because of FLAG_ACTIVITY_NEW_TASK + // above; only the callback has a main-thread requirement. context.startActivity(chooser) - onResult(true) + deliverOnMain(true, onResult) } catch (e: Exception) { e.printStackTrace() - onResult(false) + deliverOnMain(false, onResult) } } } diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/pota/PotaAdifExporterThreadingTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/pota/PotaAdifExporterThreadingTest.kt new file mode 100644 index 000000000..8850729e7 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/pota/PotaAdifExporterThreadingTest.kt @@ -0,0 +1,75 @@ +package radio.ks3ckc.ft8af.ui.pota + +import android.os.Looper +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** + * The main-thread guarantee on PotaAdifExporter's result callback (issue #700). + * + * shareActivationAdif does its work on Dispatchers.IO, and its callers touch UI + * in the callback — the POTA screen shows a Toast, which throws "Can't toast on + * a thread that has not called Looper.prepare()" anywhere but the main thread. + * Sharing an activation with no QSOs takes the empty-documents exit and crashed + * the app that way, so what needs pinning is the *thread* the callback arrives + * on, not the sharing itself (that part is DB/Intent-bound). + */ +@RunWith(RobolectricTestRunner::class) +class PotaAdifExporterThreadingTest { + + @Test + fun deliverOnMain_fromBackgroundThread_runsCallbackOnMainLooper() { + val ran = AtomicBoolean(false) + val callbackLooper = AtomicReference(null) + val received = AtomicReference(null) + + // The crashing path: reporting failure from a worker thread, as the IO + // coroutine does when an activation has no QSOs to share. + val worker = Thread { + PotaAdifExporter.deliverOnMain(false) { result -> + ran.set(true) + callbackLooper.set(Looper.myLooper()) + received.set(result) + } + } + worker.start() + worker.join() + + // It must be posted, not run inline on the worker — that inline call is + // precisely what threw before. + assertThat(ran.get()).isFalse() + + shadowOf(Looper.getMainLooper()).idle() + + assertThat(ran.get()).isTrue() + assertThat(callbackLooper.get()).isEqualTo(Looper.getMainLooper()) + assertThat(received.get()).isFalse() + } + + @Test + fun deliverOnMain_fromMainThread_runsCallbackImmediately() { + // Robolectric runs the test body on the main thread, which is also where + // the null-database early return happens. Posting there would defer a + // result the caller can have now, so it is delivered inline instead. + val received = AtomicReference(null) + + PotaAdifExporter.deliverOnMain(true) { received.set(it) } + + assertThat(received.get()).isTrue() + } + + @Test + fun deliverOnMain_passesTheResultThrough() { + // Both outcomes reach the caller unchanged: the screen only toasts on + // false, so a flipped value would silently swallow the error message. + val seen = mutableListOf() + PotaAdifExporter.deliverOnMain(true) { seen.add(it) } + PotaAdifExporter.deliverOnMain(false) { seen.add(it) } + assertThat(seen).containsExactly(true, false).inOrder() + } +} From e1ef54e4b53f4cf35ff4621f31ac0d9b3b90de9f Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sun, 2 Aug 2026 08:24:30 -0500 Subject: [PATCH 110/113] Add a "New Prefix" (WPX) decode highlight + filter for prefix chasers (#695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a "New Prefix" (WPX) decode highlight + filter for prefix chasers Worked All Prefixes (CQ WPX) is one of the most-chased amateur-radio award programs, but until now the decode list could flag new DXCC entities, zones, states, grids and bands — not new callsign prefixes. This adds a "New Prefix" highlight pill and decode filter that mark CQ stations whose WPX prefix (e.g. W1, VE3, DL0) the operator hasn't logged yet, so prefix hunters can spot a new one at a glance and one-tap-filter the list down to only new prefixes. - WpxPrefix.of() is a pure, dependency-free CQ WPX prefix extractor (simple calls, no-numeral historic calls, portable numbers CALL/n, portable prefixes pfx/CALL, ignored /P /M /QRP suffixes; non-callsigns return null). Shared by the DB worked-prefix loader and the live decode predicate so they can't drift. - GetAllQSLCallsign builds a distinct worked-prefix set (any band), mirroring the existing worked-grid set. - New NEW_PREFIX status pill, isNewPrefixStation predicate, "New Prefix" filter chip + empty state, and a Settings → Decode Highlights toggle (off by default, like New Grid — early on most prefixes are "new"). Tests: WpxPrefixTest covers the extractor across simple/compound/edge cases; NewPrefixTest covers the predicate, the pill priority, and the filter. Co-Authored-By: Claude Opus 4.8 (1M context) * Derive the portable-number prefix from the base call's own prefix (PR #695) CALL/n lifted the leading letters off the raw token and appended the new number. That works only for calls whose prefix starts with letters and carries a numeral, which is what the tests covered. Two shapes it got wrong. A digit-leading call has no leading letters at all, so 9A1AA/7 lifted "" and returned null — a prefix chaser simply never saw it. A historic call with no numeral is all letters, so RAEM/4 lifted the whole token and produced "RAEM4", which is not a prefix. Both already resolve correctly as plain calls (9A1AA -> 9A1, RAEM -> RA0), so the portable form now runs the base through that same rule and swaps the trailing numeral: 9A1AA/7 -> 9A7, RAEM/4 -> RA4. Letter-leading calls are unchanged. It also inherits simple()'s conservatism, which the old path bypassed: W1/7 and FN42/7 are a bare prefix and a grid, not callsigns, and now stay null instead of inventing W7 and FN7. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Optio Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/GeneralVariables.java | 12 + .../com/k1af/ft8af/callsign/WpxPrefix.java | 228 ++++++++++++++++++ .../com/k1af/ft8af/database/DatabaseOpr.java | 24 ++ .../kotlin/radio/ks3ckc/ft8af/theme/Color.kt | 3 + .../ks3ckc/ft8af/ui/components/StatusPill.kt | 6 + .../radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt | 19 +- .../ks3ckc/ft8af/ui/decode/DecodeScreen.kt | 8 +- .../ft8af/ui/settings/DecodeFilterSettings.kt | 14 ++ .../src/main/res/values/strings_compose.xml | 6 + .../k1af/ft8af/callsign/WpxPrefixTest.java | 110 +++++++++ .../ks3ckc/ft8af/ui/decode/NewPrefixTest.kt | 133 ++++++++++ 11 files changed, 561 insertions(+), 2 deletions(-) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/callsign/WpxPrefix.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/callsign/WpxPrefixTest.java create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/NewPrefixTest.kt diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index b377493a7..d43307435 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -612,6 +612,7 @@ public static String excludedBandsToCsv() { public static HashSet QSL_Callsign_list_today = new HashSet<>();//Callsigns worked today or yesterday (any band); a set for O(1) membership checks public static HashSet QSL_Grid_list = new HashSet<>();//Distinct worked 4-char Maidenhead grids (any band) public static HashSet QSL_Pota_list = new HashSet<>();//Distinct hunted POTA park refs (UPPER), any band + public static HashSet QSL_Prefix_list = new HashSet<>();//Distinct worked CQ WPX prefixes (any band), see WpxPrefix // Decode-list highlight toggles (Settings → Decode Highlights). Gate the // status pill shown for each worked-before category in resolveQsoStatus(). @@ -619,6 +620,7 @@ public static String excludedBandsToCsv() { public static boolean highlightNewZone = true;//Highlight stations from an unworked CQ zone (Worked All Zones) public static boolean highlightNewState = false;//Off by default — US-only (Worked All States); noise for non-US ops public static boolean highlightNewGrid = false;//Off by default — most grids are "new", so it's noisy + public static boolean highlightNewPrefix = false;//Off by default — many prefixes are "new" early on (Worked All Prefixes / WPX) public static boolean highlightNewBand = true;//Highlight stations worked only on other bands public static boolean highlightWorked = true;//Master enable for worked-station handling (see workedStationMode) public static boolean highlightPota = true;//Highlight spotted POTA activators (new parks stand out) @@ -982,6 +984,16 @@ public static boolean checkQSLGrid(String grid) { return QSL_Grid_list.contains(grid.substring(0, 4).toUpperCase()); } + /** + * Check whether a CQ WPX prefix (e.g. "W1", "DL0") has already been worked on + * any band. Caller should pass a prefix already normalized by + * {@link com.k1af.ft8af.callsign.WpxPrefix#of(String)} (upper case). + */ + public static boolean checkQSLPrefix(String prefix) { + if (prefix == null || prefix.isEmpty()) return false; + return QSL_Prefix_list.contains(prefix); + } + /** * Check if a POTA park reference (e.g. "K-1234") has been previously hunted (any band). */ diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/callsign/WpxPrefix.java b/ft8af/app/src/main/java/com/k1af/ft8af/callsign/WpxPrefix.java new file mode 100644 index 000000000..6b80d24ab --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/callsign/WpxPrefix.java @@ -0,0 +1,228 @@ +package com.k1af.ft8af.callsign; + +import java.util.ArrayList; +import java.util.Locale; + +/** + * Extract the CQ WPX prefix from a callsign. + * + *

WPX (Worked All Prefixes) is one of the most-chased amateur-radio award + * programs: each distinct callsign prefix (e.g. {@code W1}, {@code VE3}, + * {@code 9A1}, {@code DL0}) counts once, so operators hunt for "new prefixes" + * the same way they hunt new DXCC entities or grids. This helper turns a + * decoded callsign into its prefix so the decode list can flag unworked ones. + * + *

Rules implemented (per the CQ WPX definition), scoped to what actually + * appears in FT8 traffic: + *

    + *
  • Simple call — the letters/numerals up to and including the last numeral + * of the prefix. {@code W1AW}→{@code W1}, {@code VE3ABC}→{@code VE3}, + * {@code 9A1AA}→{@code 9A1}, {@code 3DA0RS}→{@code 3DA0}.
  • + *
  • Call with no numeral (rare/historic, e.g. {@code RAEM}) — first two + * letters plus {@code 0} → {@code RA0}.
  • + *
  • Portable number {@code CALL/n} — the base call's own prefix with its + * numeral swapped for the new one. {@code W1AW/4}→{@code W4}, + * {@code VE3ABC/7}→{@code VE7}, {@code 9A1AA/7}→{@code 9A7}, + * {@code RAEM/4}→{@code RA4}. Deriving the base with the same + * simple-call rule (rather than lifting leading letters off the raw token) + * is what makes digit-leading and no-numeral calls behave; it also means a + * token that isn't a whole callsign — {@code W1/7}, {@code FN42/7} — stays + * {@code null} instead of inventing a prefix.
  • + *
  • Portable prefix {@code pfx/CALL} — the designator prefix, adding a + * {@code 0} when it carries no numeral. {@code DL/W1AW}→{@code DL0}, + * {@code PJ4/K1ABC}→{@code PJ4}, {@code W1AW/KH6}→{@code KH6}.
  • + *
  • Operational suffixes {@code /P /M /MM /AM /QRP} are ignored (they don't + * change the prefix). {@code G4XYZ/P}→{@code G4}.
  • + *
+ * + *

Anything that isn't a plausible callsign (grids, signal reports, hashed + * {@code <...>} calls, empty/garbled tokens) returns {@code null} so callers + * simply don't flag it — a conservative choice that never invents a prefix. + * Pure and dependency-free so it can be unit-tested on the host and shared by + * both the DB worked-prefix loader (DatabaseOpr.GetAllQSLCallsign) and the + * decode-list "New Prefix" predicate (DecodeRow.isNewPrefixStation). + */ +public final class WpxPrefix { + + private WpxPrefix() { + } + + /** + * @param raw a callsign (case/whitespace-insensitive), possibly compound + * @return the WPX prefix in upper case, or {@code null} if none can be + * determined + */ + public static String of(String raw) { + if (raw == null) { + return null; + } + String call = raw.trim().toUpperCase(Locale.ROOT); + if (call.isEmpty()) { + return null; + } + if (call.indexOf('/') < 0) { + return simple(call); + } + return compound(call); + } + + /** Prefix of a plain (non-slashed) callsign, or null if implausible. */ + private static String simple(String call) { + if (!isAlnum(call)) { + return null; + } + int lastDigit = -1; + boolean hasLetter = false; + for (int i = 0; i < call.length(); i++) { + char c = call.charAt(i); + if (c >= '0' && c <= '9') { + lastDigit = i; + } else { + hasLetter = true; + } + } + if (!hasLetter) { + return null; // all-digit token (signal report, "73", …) — not a call + } + if (lastDigit < 0) { + // No numeral at all (historic call like RAEM): first two letters + 0. + return call.length() >= 2 ? call.substring(0, 2) + "0" : null; + } + // A full callsign carries a letter suffix after its prefix numeral. + // Requiring one keeps bare prefixes / partial tokens ("W1") and grids + // ("FN42") from being mistaken for a whole callsign. + if (lastDigit == call.length() - 1) { + return null; + } + String prefix = call.substring(0, lastDigit + 1); + return containsLetter(prefix) ? prefix : null; + } + + /** Prefix of a compound (slashed) callsign, or null if indeterminate. */ + private static String compound(String call) { + ArrayList parts = new ArrayList<>(); + for (String p : call.split("/")) { + if (!p.isEmpty() && !isIgnoredSuffix(p)) { + parts.add(p); + } + } + if (parts.isEmpty()) { + return null; + } + if (parts.size() == 1) { + return simple(parts.get(0)); + } + + // Portable number: CALL/n → the base call's own prefix with its numeral + // swapped for the new one. + String numeric = null; + for (String p : parts) { + if (isAllDigits(p) && p.length() <= 2) { + numeric = p; + } + } + if (numeric != null) { + for (String p : parts) { + if (!isAllDigits(p)) { + String base = simple(p); + return base == null ? null : withPortableNumber(base, numeric); + } + } + return null; + } + + // Portable prefix: pfx/CALL. The designator is conventionally the shorter + // token (e.g. "DL" in DL/W1AW, "KH6" in W1AW/KH6). + String a = parts.get(0); + String b = parts.get(1); + String designator = a.length() <= b.length() ? a : b; + if (!isAlnum(designator) || !containsLetter(designator)) { + return null; + } + int lastDigit = -1; + for (int i = 0; i < designator.length(); i++) { + char c = designator.charAt(i); + if (c >= '0' && c <= '9') { + lastDigit = i; + } + } + if (lastDigit >= 0) { + return designator.substring(0, lastDigit + 1); + } + // Designator without a numeral (bare prefix like "DL"): first two letters + 0. + return designator.length() >= 2 + ? designator.substring(0, 2) + "0" + : designator + "0"; + } + + private static boolean isIgnoredSuffix(String s) { + switch (s) { + case "P": + case "M": + case "MM": + case "AM": + case "QRP": + case "QRPP": + return true; + default: + return false; + } + } + + private static boolean isAlnum(String s) { + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) { + return false; + } + } + return true; + } + + private static boolean isAllDigits(String s) { + if (s.isEmpty()) { + return false; + } + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + return true; + } + + private static boolean containsLetter(String s) { + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c >= 'A' && c <= 'Z') { + return true; + } + } + return false; + } + + /** + * Swap the trailing numerals of an already-derived prefix for the portable + * number, e.g. {@code ("9A1","7")}→{@code 9A7}, {@code ("RA0","4")}→ + * {@code RA4}, {@code ("W1","4")}→{@code W4}. + * + *

Working from {@link #simple(String)}'s output rather than the raw token's + * leading letters is what makes the digit-leading and no-numeral cases come + * out right: {@code 9A1AA} has no leading letters at all, and {@code RAEM} + * is all letters, so lifting letters off the raw call yielded nothing or the + * whole token. {@code simple} has already resolved both to a real prefix + * ({@code 9A1}, {@code RA0}), and every prefix it returns ends in a numeral, + * so replacing that numeral is well defined. + * + * @return the adjusted prefix, or {@code null} if nothing but digits remains + */ + private static String withPortableNumber(String base, String number) { + int end = base.length(); + while (end > 0 && base.charAt(end - 1) >= '0' && base.charAt(end - 1) <= '9') { + end--; + } + String letters = base.substring(0, end); + return letters.isEmpty() ? null : letters + number; + } +} diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 2f231e7b6..29fd5f6e8 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2454,6 +2454,27 @@ public static void get(SQLiteDatabase db) { } GeneralVariables.QSL_Grid_list = grids; + // Load distinct worked CQ WPX prefixes (any band) into an in-memory + // set backing the decode list's "New Prefix" highlight/filter. Derive + // each prefix from the logged callsign via the shared WpxPrefix helper + // so the "worked" set and the live decode predicate agree exactly. + querySQL = "select distinct [call] from QSLTable where [call] is not null and [call]<>''"; + cursor = db.rawQuery(querySQL, null); + java.util.HashSet prefixes = new java.util.HashSet<>(); + try { + while (cursor.moveToNext()) { + @SuppressLint("Range") + String c = cursor.getString(cursor.getColumnIndex("call")); + String pfx = com.k1af.ft8af.callsign.WpxPrefix.of(c); + if (pfx != null) { + prefixes.add(pfx); + } + } + } finally { + cursor.close(); + } + GeneralVariables.QSL_Prefix_list = prefixes; + // Load distinct hunted POTA park refs (any band) into in-memory set. // sig/sig_info may be absent on some upgraded installs (see note in // onUpgrade), so guard the query defensively. @@ -3150,6 +3171,9 @@ protected Void doInBackground(Void... voids) { if (name.equalsIgnoreCase("highlightNewGrid")) { GeneralVariables.highlightNewGrid = result.equals("1"); } + if (name.equalsIgnoreCase("highlightNewPrefix")) { + GeneralVariables.highlightNewPrefix = result.equals("1"); + } if (name.equalsIgnoreCase("highlightNewBand")) { GeneralVariables.highlightNewBand = result.equals("1"); } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/theme/Color.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/theme/Color.kt index c80841cae..1adb9a22e 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/theme/Color.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/theme/Color.kt @@ -163,6 +163,9 @@ val StatusZone = Color(0xFF60A5FA) // Teal, distinct from the blue NEW-ZONE, cyan NEW-BAND and green POTA pills, for // the NEW-STATE (Worked All States) badge. val StatusState = Color(0xFF2DD4BF) +// Rose/pink, distinct from the purple NEW-DXCC, blue NEW-ZONE, teal NEW-STATE and +// yellow NEW-GRID badges, for the NEW-PREFIX (Worked All Prefixes / WPX) badge. +val StatusPrefix = Color(0xFFF472B6) val StatusBad = Color(0xFFEF4444) // Band colors (for donut chart / logbook) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/StatusPill.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/StatusPill.kt index d600b045b..e151b7866 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/StatusPill.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/StatusPill.kt @@ -54,6 +54,12 @@ enum class QsoStatus( Color(0x1FFACC15), // rgba(250,204,21,0.12) Color(0x47FACC15), // rgba(250,204,21,0.28) ), + NEW_PREFIX( + R.string.status_new_prefix, + StatusPrefix, + Color(0x1FF472B6), // rgba(244,114,182,0.12) + Color(0x47F472B6), // rgba(244,114,182,0.28) + ), NEW_BAND( R.string.status_new_band, Signal, diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt index 73da166bc..602fdd91f 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeRow.kt @@ -376,6 +376,19 @@ internal fun isNewGridStation(message: Ft8Message): Boolean { !GeneralVariables.checkQSLGrid(grid) } +/** + * Whether [message]'s sender carries a CQ WPX prefix (e.g. "W1", "DL0") the + * operator hasn't logged yet — a "new prefix" for Worked-All-Prefixes chasing. + * The prefix is derived from the callsign via [com.k1af.ft8af.callsign.WpxPrefix] + * (the same helper that builds the worked-prefix set in the log loader), so the + * NEW_PREFIX pill and the "New Prefix" filter always agree. Callsigns that don't + * yield a prefix (grids, reports, hashed calls) are never counted as new. + */ +internal fun isNewPrefixStation(message: Ft8Message): Boolean { + val prefix = com.k1af.ft8af.callsign.WpxPrefix.of(message.callsignFrom) ?: return false + return !GeneralVariables.checkQSLPrefix(prefix) +} + /** * Resolve the [QsoStatus] for a given [Ft8Message] based on its state. * @@ -384,7 +397,7 @@ internal fun isNewGridStation(message: Ft8Message): Boolean { * should skip rendering the pill in that case. * * Priority (highest first): calling me, POTA/SOTA activation, new DXCC, - * new grid, new band, plain CQ, already worked. + * new zone, new state, new grid, new prefix, new band, plain CQ, already worked. */ internal fun resolveQsoStatus(message: Ft8Message): QsoStatus? { val isCQ = message.checkIsCQ() @@ -397,6 +410,7 @@ internal fun resolveQsoStatus(message: Ft8Message): QsoStatus? { val modifier = message.modifier val newGrid = isNewGridStation(message) + val newPrefix = isNewPrefixStation(message) val newBand = !isWorked && GeneralVariables.checkQSLCallsign_OtherBand(message.callsignFrom ?: "") @@ -435,6 +449,9 @@ internal fun resolveQsoStatus(message: Ft8Message): QsoStatus? { // leave it false. GeneralVariables.highlightNewState && message.fromNewState -> QsoStatus.NEW_STATE GeneralVariables.highlightNewGrid && newGrid -> QsoStatus.NEW_GRID + // A new WPX prefix (Worked All Prefixes) ranks just below a new grid: both + // are common early on, so they sit under the rarer DXCC/zone/state catches. + GeneralVariables.highlightNewPrefix && newPrefix -> QsoStatus.NEW_PREFIX GeneralVariables.highlightNewBand && newBand -> QsoStatus.NEW_BAND effectiveWorkedMode() == WorkedStationMode.HIGHLIGHT && isWorkedStation(message) -> QsoStatus.WORKED diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt index 7ae3829bf..e0c273a83 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeScreen.kt @@ -67,7 +67,7 @@ fun DecodeScreen( // Filter state. Backed by the ViewModel so the chosen filter survives // navigation away from Decode and back (the screen is recreated by the // tab switch, which would otherwise reset a local rememberSaveable). - val filterOptions = listOf("All", "CQ Calls", "CQ POTA", "New DXCC", "New Zone", "New State", "New Grid", "Needed", "For Me") + val filterOptions = listOf("All", "CQ Calls", "CQ POTA", "New DXCC", "New Zone", "New State", "New Grid", "New Prefix", "Needed", "For Me") val selectedFilter by mainViewModel.decodeFilter.observeAsState("All") // Couple the "CQ POTA" display filter to Hunt: while it's selected, the auto-call @@ -525,6 +525,7 @@ private fun filterLabel(key: String): String = when (key) { "New Zone" -> stringResource(R.string.decode_filter_new_zone) "New State" -> stringResource(R.string.decode_filter_new_state) "New Grid" -> stringResource(R.string.decode_filter_new_grid) + "New Prefix" -> stringResource(R.string.decode_filter_new_prefix) "Needed" -> stringResource(R.string.decode_filter_needed) "For Me" -> stringResource(R.string.decode_filter_for_me) else -> stringResource(R.string.decode_filter_all) @@ -604,6 +605,10 @@ internal fun filterMessages( // stations whose grid field the operator hasn't logged yet, so the list // becomes a one-tap "who's calling from a grid I still need" view. "New Grid" -> base.filter { it.checkIsCQ() && isNewGridStation(it) } + // Mirror of "New DXCC" for prefix chasers (Worked All Prefixes / WPX): + // only CQ stations whose callsign prefix the operator hasn't logged yet, + // so the list becomes a one-tap "who's a new prefix" view. + "New Prefix" -> base.filter { it.checkIsCQ() && isNewPrefixStation(it) } "Needed" -> base.filter { !it.isQSL_Callsign && !GeneralVariables.checkQSLCallsign(it.callsignFrom ?: "") @@ -631,6 +636,7 @@ internal fun EmptyState( "New Zone" -> stringResource(R.string.decode_empty_zone_title) to stringResource(R.string.decode_empty_zone_body) "New State" -> stringResource(R.string.decode_empty_state_title) to stringResource(R.string.decode_empty_state_body) "New Grid" -> stringResource(R.string.decode_empty_grid_title) to stringResource(R.string.decode_empty_grid_body) + "New Prefix" -> stringResource(R.string.decode_empty_prefix_title) to stringResource(R.string.decode_empty_prefix_body) "Needed" -> stringResource(R.string.decode_empty_needed_title) to stringResource(R.string.decode_empty_needed_body) "For Me" -> stringResource(R.string.decode_empty_forme_title) to stringResource(R.string.decode_empty_forme_body) else -> stringResource(R.string.decode_empty_default_title) to stringResource(R.string.decode_empty_default_body, GeneralVariables.currentMode().displayName) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt index 0138aa431..3d925aa57 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/DecodeFilterSettings.kt @@ -30,6 +30,7 @@ fun DecodeFilterSettings( var highlightNewZone by remember { mutableStateOf(GeneralVariables.highlightNewZone) } var highlightNewState by remember { mutableStateOf(GeneralVariables.highlightNewState) } var highlightNewGrid by remember { mutableStateOf(GeneralVariables.highlightNewGrid) } + var highlightNewPrefix by remember { mutableStateOf(GeneralVariables.highlightNewPrefix) } var highlightNewBand by remember { mutableStateOf(GeneralVariables.highlightNewBand) } var highlightWorked by remember { mutableStateOf(GeneralVariables.highlightWorked) } var workedStationMode by remember { mutableStateOf(GeneralVariables.workedStationMode) } @@ -286,6 +287,19 @@ fun DecodeFilterSettings( }, ) SectionDivider() + SettingsRow( + label = stringResource(R.string.settings_highlight_new_prefix), + description = stringResource(R.string.settings_highlight_new_prefix_desc), + toggle = highlightNewPrefix, + onToggleChange = { checked -> + highlightNewPrefix = checked + GeneralVariables.highlightNewPrefix = checked + mainViewModel.databaseOpr.writeConfig( + "highlightNewPrefix", if (checked) "1" else "0", null, + ) + }, + ) + SectionDivider() SettingsRow( label = stringResource(R.string.settings_highlight_new_band), description = stringResource(R.string.settings_highlight_new_band_desc), diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 850fe44bd..c042dbaf7 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -49,6 +49,7 @@ NEW ZONE NEW STATE NEW GRID + NEW PFX NEW BAND POTA NEW POTA @@ -206,6 +207,7 @@ New Zone New State New Grid + New Prefix Needed For Me No CQ calls @@ -220,6 +222,8 @@ No stations calling from an unworked US state have been decoded yet. No new grids No stations calling from an unworked grid square have been decoded yet. + No new prefixes + No stations calling from an unworked callsign prefix (WPX) have been decoded yet. Nothing needed No stations needing confirmation found. No calls for you @@ -645,6 +649,8 @@ Highlight not-yet-worked US states (Worked All States) New Grid Highlight not-yet-worked Maidenhead grids + New Prefix + Highlight not-yet-worked callsign prefixes (Worked All Prefixes / WPX) New Band Highlight stations worked only on other bands POTA Activators diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/callsign/WpxPrefixTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/callsign/WpxPrefixTest.java new file mode 100644 index 000000000..f9cb981b3 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/callsign/WpxPrefixTest.java @@ -0,0 +1,110 @@ +package com.k1af.ft8af.callsign; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Unit tests for {@link WpxPrefix#of(String)} — the CQ WPX prefix extractor that + * backs the decode list's "New Prefix" highlight/filter. Pure logic, no runner. + */ +public class WpxPrefixTest { + + @Test + public void simpleCalls() { + assertThat(WpxPrefix.of("W1AW")).isEqualTo("W1"); + assertThat(WpxPrefix.of("K5XYZ")).isEqualTo("K5"); + assertThat(WpxPrefix.of("VE3ABC")).isEqualTo("VE3"); + assertThat(WpxPrefix.of("EA8XYZ")).isEqualTo("EA8"); + assertThat(WpxPrefix.of("PY2AA")).isEqualTo("PY2"); + assertThat(WpxPrefix.of("KH6ABC")).isEqualTo("KH6"); + } + + @Test + public void leadingDigitPrefixes() { + // Countries whose prefix starts with a digit keep every char up to and + // including the last prefix numeral. + assertThat(WpxPrefix.of("9A1AA")).isEqualTo("9A1"); + assertThat(WpxPrefix.of("4X4AAA")).isEqualTo("4X4"); + assertThat(WpxPrefix.of("3DA0RS")).isEqualTo("3DA0"); + } + + @Test + public void caseAndWhitespaceInsensitive() { + assertThat(WpxPrefix.of(" ve3abc ")).isEqualTo("VE3"); + } + + @Test + public void noNumeralCall_getsZero() { + // Historic calls with no digit: first two letters + 0. + assertThat(WpxPrefix.of("RAEM")).isEqualTo("RA0"); + } + + @Test + public void portableNumber_replacesPrefixDigit() { + assertThat(WpxPrefix.of("W1AW/4")).isEqualTo("W4"); + assertThat(WpxPrefix.of("VE3ABC/7")).isEqualTo("VE7"); + assertThat(WpxPrefix.of("PY2AA/0")).isEqualTo("PY0"); + } + + @Test + public void portableNumber_digitLeadingPrefix() { + // The prefix starts with a numeral, so the call has no leading letters to + // lift — the base prefix has to come from the simple-call rule first. + assertThat(WpxPrefix.of("9A1AA/7")).isEqualTo("9A7"); + assertThat(WpxPrefix.of("4X4AAA/1")).isEqualTo("4X1"); + // Only the prefix's own numeral is replaced, not the leading digit. + assertThat(WpxPrefix.of("3DA0RS/7")).isEqualTo("3DA7"); + } + + @Test + public void portableNumber_noNumeralCall() { + // RAEM has no digit at all: it resolves to RA0 on its own, so a portable + // number swaps that 0 rather than being appended to the whole call. + assertThat(WpxPrefix.of("RAEM/4")).isEqualTo("RA4"); + } + + @Test + public void portableNumber_onNonCallsignToken_staysNull() { + // A bare prefix or a grid isn't a whole callsign, so adding a portable + // number must not conjure one up. + assertThat(WpxPrefix.of("W1/7")).isNull(); + assertThat(WpxPrefix.of("FN42/7")).isNull(); + } + + @Test + public void portablePrefix_designatorWins() { + assertThat(WpxPrefix.of("DL/W1AW")).isEqualTo("DL0"); + assertThat(WpxPrefix.of("PJ4/K1ABC")).isEqualTo("PJ4"); + assertThat(WpxPrefix.of("PA3/G4XYZ")).isEqualTo("PA3"); + // Home call listed first, portable region second — still the region wins. + assertThat(WpxPrefix.of("W1AW/KH6")).isEqualTo("KH6"); + } + + @Test + public void operationalSuffixesIgnored() { + assertThat(WpxPrefix.of("G4XYZ/P")).isEqualTo("G4"); + assertThat(WpxPrefix.of("W1AW/M")).isEqualTo("W1"); + assertThat(WpxPrefix.of("VK2ABC/QRP")).isEqualTo("VK2"); + assertThat(WpxPrefix.of("K1ABC/MM")).isEqualTo("K1"); + // Suffix combined with a portable number: number still applies. + assertThat(WpxPrefix.of("W1AW/4/QRP")).isEqualTo("W4"); + } + + @Test + public void nonCallsignTokens_returnNull() { + assertThat(WpxPrefix.of(null)).isNull(); + assertThat(WpxPrefix.of("")).isNull(); + assertThat(WpxPrefix.of(" ")).isNull(); + assertThat(WpxPrefix.of("73")).isNull(); + assertThat(WpxPrefix.of("RR73")).isNull(); + assertThat(WpxPrefix.of("599")).isNull(); + assertThat(WpxPrefix.of("<...>")).isNull(); + assertThat(WpxPrefix.of("R-12")).isNull(); + // A Maidenhead grid ends in its digits, so it has no letter suffix and is + // correctly rejected as a callsign. + assertThat(WpxPrefix.of("FN42")).isNull(); + // Bare prefix with no suffix isn't a whole callsign. + assertThat(WpxPrefix.of("W1")).isNull(); + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/NewPrefixTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/NewPrefixTest.kt new file mode 100644 index 000000000..7144e9803 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/NewPrefixTest.kt @@ -0,0 +1,133 @@ +package radio.ks3ckc.ft8af.ui.decode + +import com.k1af.ft8af.Ft8Message +import com.k1af.ft8af.GeneralVariables +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import radio.ks3ckc.ft8af.ui.components.QsoStatus + +/** + * Unit-tests the shared [isNewPrefixStation] predicate (and its NEW_PREFIX pill / + * "New Prefix" filter) that back the Worked-All-Prefixes (WPX) chase, so the pill + * and the filter never disagree on what counts as an unworked prefix. + * + * Robolectric is needed only because [Ft8Message] reaches android.util.Log on + * construction; the logic under test is pure. + */ +@RunWith(RobolectricTestRunner::class) +class NewPrefixTest { + + private var savedHighlightPota = false + private var savedHighlightNewDxcc = false + private var savedHighlightNewZone = false + private var savedHighlightNewState = false + private var savedHighlightNewGrid = false + private var savedHighlightNewPrefix = false + private var savedHighlightNewBand = false + private var savedHighlightWorked = false + + @Before + fun setUp() { + savedHighlightPota = GeneralVariables.highlightPota + savedHighlightNewDxcc = GeneralVariables.highlightNewDxcc + savedHighlightNewZone = GeneralVariables.highlightNewZone + savedHighlightNewState = GeneralVariables.highlightNewState + savedHighlightNewGrid = GeneralVariables.highlightNewGrid + savedHighlightNewPrefix = GeneralVariables.highlightNewPrefix + savedHighlightNewBand = GeneralVariables.highlightNewBand + savedHighlightWorked = GeneralVariables.highlightWorked + GeneralVariables.QSL_Prefix_list.clear() + GeneralVariables.QSL_Callsign_list.clear() + } + + @After + fun tearDown() { + GeneralVariables.highlightPota = savedHighlightPota + GeneralVariables.highlightNewDxcc = savedHighlightNewDxcc + GeneralVariables.highlightNewZone = savedHighlightNewZone + GeneralVariables.highlightNewState = savedHighlightNewState + GeneralVariables.highlightNewGrid = savedHighlightNewGrid + GeneralVariables.highlightNewPrefix = savedHighlightNewPrefix + GeneralVariables.highlightNewBand = savedHighlightNewBand + GeneralVariables.highlightWorked = savedHighlightWorked + GeneralVariables.QSL_Prefix_list.clear() + GeneralVariables.QSL_Callsign_list.clear() + } + + private fun cqFrom(from: String): Ft8Message = Ft8Message("CQ", from, "TEST") + + @Test + fun unworkedPrefix_isNew() { + assertThat(isNewPrefixStation(cqFrom("W1ABC"))).isTrue() + } + + @Test + fun workedPrefix_isNotNew() { + GeneralVariables.QSL_Prefix_list.add("W1") + assertThat(isNewPrefixStation(cqFrom("W1ABC"))).isFalse() + } + + @Test + fun differentSuffixSamePrefix_isNotNew() { + // Prefix keys on "W1", not the whole call, so a different W1 station is + // no longer a new prefix once any W1 has been worked. + GeneralVariables.QSL_Prefix_list.add("W1") + assertThat(isNewPrefixStation(cqFrom("W1XYZ"))).isFalse() + // ...but a different prefix from the same country is still new. + assertThat(isNewPrefixStation(cqFrom("W5XYZ"))).isTrue() + } + + @Test + fun nonCallsignFrom_isNotNew() { + // A token that yields no WPX prefix must never be flagged. + assertThat(isNewPrefixStation(cqFrom("73"))).isFalse() + } + + @Test + fun newPrefixPill_surfacesWhenHighlightEnabled() { + GeneralVariables.highlightPota = false + GeneralVariables.highlightNewDxcc = false + GeneralVariables.highlightNewZone = false + GeneralVariables.highlightNewState = false + GeneralVariables.highlightNewGrid = false + GeneralVariables.highlightNewPrefix = true + GeneralVariables.highlightNewBand = false + GeneralVariables.highlightWorked = false + + assertThat(resolveQsoStatus(cqFrom("DL1ABC"))) + .isEqualTo(QsoStatus.NEW_PREFIX) + } + + @Test + fun newPrefixPill_suppressedWhenPrefixWorked() { + GeneralVariables.highlightPota = false + GeneralVariables.highlightNewDxcc = false + GeneralVariables.highlightNewZone = false + GeneralVariables.highlightNewState = false + GeneralVariables.highlightNewGrid = false + GeneralVariables.highlightNewPrefix = true + GeneralVariables.highlightNewBand = false + GeneralVariables.highlightWorked = false + GeneralVariables.QSL_Prefix_list.add("DL1") + + // Worked prefix falls through to the plain CQ pill. + assertThat(resolveQsoStatus(cqFrom("DL1ABC"))) + .isEqualTo(QsoStatus.CQ) + } + + @Test + fun newPrefixFilter_keepsOnlyUnworkedCqPrefixes() { + GeneralVariables.QSL_Prefix_list.add("W1") + val messages = listOf( + cqFrom("W1ABC"), // worked prefix -> dropped + cqFrom("VE3XYZ"), // new prefix, CQ -> kept + Ft8Message("K9AAA", "DL1ABC", "TEST"), // new prefix but not a CQ -> dropped + ) + val filtered = filterMessages(messages, "New Prefix") + assertThat(filtered.map { it.callsignFrom }).containsExactly("VE3XYZ") + } +} From 69dafd2112d5fb37487c77cde2fd4e82d13dc4d7 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sun, 2 Aug 2026 08:28:58 -0500 Subject: [PATCH 111/113] Drop the non-ADIF QSL_MANUAL tag from the desktop and iOS exporters (#719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QSL_MANUAL is not a field in the ADIF spec, so strict importers (LoTW's validator, Club Log, other loggers) can reject or silently drop it. PR #701 fixed this on Android by moving the flag to APP_FT8AF_QSL_MANUAL — the spec's APP__ escape hatch — but the desktop and iOS ports were missed and still emit the bare name. Neither port needs the APP_ field, because on both the tag is a hardcoded "N" carrying no information: desktop's QSL_RCVD tracks r.confirmed, but QSL_MANUAL was always N, and iOS's QsoRecord has no confirmation state at all. Nothing reads it back either — desktop has no ADIF import path, and the iOS parser ignores QSL flags. Android's importer keys on the field being present, so an absent field and an explicit N are the same import. Dropping it is therefore lossless. Covers both desktop emitters: the file export and adif_record(), which is what goes out over the WSJT-X "Logged ADIF" UDP message to JTAlert/N1MM — the one most likely to meet a strict parser. Fixes #697 Co-authored-by: Claude Opus 5 (1M context) --- desktop/src-tauri/src/db.rs | 27 +++++++++++++++++-- ios/FT8AFKit/Sources/FT8Engine/Adif.swift | 7 ++--- .../Tests/FT8EngineTests/AdifTests.swift | 4 +-- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/db.rs b/desktop/src-tauri/src/db.rs index 4b4a9626f..4cdf2aa89 100644 --- a/desktop/src-tauri/src/db.rs +++ b/desktop/src-tauri/src/db.rs @@ -375,7 +375,7 @@ impl Db { { out.push_str(&adif_field("call", &r.call)); let qsl = if r.confirmed { "Y" } else { "N" }; - out.push_str(&format!("{qsl} N ")); + out.push_str(&format!("{qsl} ")); adif_opt(&mut out, "gridsquare", &r.gridsquare); adif_opt(&mut out, "mode", &r.mode); adif_opt(&mut out, "rst_sent", &r.rst_sent); @@ -422,7 +422,7 @@ pub fn adif_record(r: &QsoRecord) -> String { let mut out = String::new(); out.push_str(&adif_field("call", &r.call)); let qsl = if r.confirmed { "Y" } else { "N" }; - out.push_str(&format!("{qsl} N ")); + out.push_str(&format!("{qsl} ")); adif_opt(&mut out, "gridsquare", &r.gridsquare); adif_opt(&mut out, "mode", &r.mode); adif_opt(&mut out, "rst_sent", &r.rst_sent); @@ -482,6 +482,29 @@ mod tests { assert!(adif.trim_end().ends_with("")); } + #[test] + fn export_omits_the_non_adif_qsl_manual_tag() { + // QSL_MANUAL is not in the ADIF spec (issue #697), and strict importers + // reject or silently drop unknown tags. Both emitters are covered: the + // file export and adif_record(), which is what goes out over the WSJT-X + // "Logged ADIF" UDP message to JTAlert/N1MM. + let db = Db::open_in_memory().unwrap(); + let confirmed = rec("K1ABC", "20260604", "120000", true); + let unconfirmed = rec("W1AW", "20260604", "130000", false); + db.insert_qso(&confirmed).unwrap(); + db.insert_qso(&unconfirmed).unwrap(); + + let adif = db.export_adif(); + assert!(!adif.contains("QSL_MANUAL")); + assert!(!adif_record(&confirmed).contains("QSL_MANUAL")); + + // QSL_RCVD is a real ADIF field and still carries the confirmed flag, + // which is the only QSL information these records ever held. + assert!(adif.contains("K1ABC Y ")); + assert!(adif.contains("W1AW N ")); + assert!(adif_record(&confirmed).contains("Y ")); + } + #[test] fn config_roundtrip() { let db = Db::open_in_memory().unwrap(); diff --git a/ios/FT8AFKit/Sources/FT8Engine/Adif.swift b/ios/FT8AFKit/Sources/FT8Engine/Adif.swift index 2fa54570b..88ce107f5 100644 --- a/ios/FT8AFKit/Sources/FT8Engine/Adif.swift +++ b/ios/FT8AFKit/Sources/FT8Engine/Adif.swift @@ -1,7 +1,7 @@ import Foundation /// ADIF import/export for the QSO log. `export` is a byte-for-byte port of desktop -/// db.rs `export_adif` (same header, field set, order, and the fixed QSL flags), +/// db.rs `export_adif` (same header, field set, order, and the fixed QSL flag), /// so logs round-trip identically across the iOS app, the desktop port, and /// ShareLogs. `parse` is the inverse (new on iOS — users import existing logs), /// a lenient length-prefixed `value` tokenizer. @@ -11,12 +11,13 @@ public enum Adif { /// Render `records` as an ADIF string in the given order. Matches desktop /// db.rs export_adif exactly: a fixed header, then per record the CALL field, - /// the fixed QSL flags, every non-empty optional field, and comment + . + /// `QSL_RCVD` (iOS has no confirmation state yet, so always `N`), every + /// non-empty optional field, and comment + . public static func export(_ records: [QsoRecord]) -> String { var out = "FT8AF ADIF Export\n" for r in records { out += field("call", r.call) - out += "N N " + out += "N " opt(&out, "gridsquare", r.gridsquare) opt(&out, "mode", r.mode) opt(&out, "rst_sent", r.rstSent) diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/AdifTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/AdifTests.swift index 10f260c1f..4593ca15d 100644 --- a/ios/FT8AFKit/Tests/FT8EngineTests/AdifTests.swift +++ b/ios/FT8AFKit/Tests/FT8EngineTests/AdifTests.swift @@ -36,7 +36,7 @@ final class AdifTests: XCTestCase { let expected = "FT8AF ADIF Export\n" + "K1ABC " - + "N N " + + "N " + "FN42 " + "FT8 " + "-08 " @@ -56,7 +56,7 @@ final class AdifTests: XCTestCase { func testExportOmitsEmptyOptionalsButKeepsCallFlagsAndComment() { let out = Adif.export([rec(call: "W1AW")]) // all optionals empty XCTAssertEqual(out, - "FT8AF ADIF Export\nW1AW N N \n") + "FT8AF ADIF Export\nW1AW N \n") } func testExportUsesUtf8ByteLengthForComment() { From 3fa9982ae115f158d3baf26b98b09b63ff024ca7 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sun, 2 Aug 2026 10:33:01 -0500 Subject: [PATCH 112/113] Self-syncing clock: auto-trim the clock offset from decode DT medians (#720) * Self-syncing clock: auto-trim the clock offset from decode DT medians Opt-in Time Sync setting that makes the band itself the time source: each slot's per-decode DTs (fast pass only, own-TX echoes excluded) feed a pure ClockSelfSync estimator - median with MAD outlier rejection, >=4 surviving samples, 0.30 s deadband (the UI's "good clock" threshold), and two consecutive same-sign slots required before acting. Corrections apply a 0.5 proportional gain (damps the measure->correct->measure loop; no hard step cap, per the GpsClockUpdater step-limiter post-mortem) and fan out through the same three-way path as the manual control (UtcTimer.delay, GeneralVariables.manualTimeCorrectionMs, timeCorrectionMs config row), so RX windows, TX key-up, and persistence all follow. Stands down (and clears its confirmation streak) while GPS clock discipline owns the clock; the settings row is disabled then too, matching the manual-correction lock-out. Co-Authored-By: Claude Fable 5 * Harden ClockSelfSync slot handling against concurrent decode threads Copilot review on #720 flagged two real ordering hazards: beginSlot dedup'd only on equality (a slow slot's delivery arriving after its successor's would be treated as new), and the beginSlot + onSlotDecodes pair was called as two separate synchronized sections, letting adjacent- slot threads interleave between dedup and streak update. beginSlot now rejects any utc <= the last processed slot, and a new atomic onSlot(utc, dt, delay) does dedup + decision under one lock; MainViewModel uses it. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../java/com/k1af/ft8af/GeneralVariables.java | 1 + .../java/com/k1af/ft8af/MainViewModel.java | 43 +++ .../com/k1af/ft8af/database/DatabaseOpr.java | 3 + .../com/k1af/ft8af/timer/ClockSelfSync.java | 202 ++++++++++++ .../ks3ckc/ft8af/ui/components/SettingsRow.kt | 2 + .../ft8af/ui/settings/TimeSyncSettings.kt | 39 +++ .../src/main/res/values/strings_compose.xml | 5 + .../DatabaseOprConfigHydrationTest.java | 32 ++ .../k1af/ft8af/timer/ClockSelfSyncTest.java | 303 ++++++++++++++++++ 9 files changed, 630 insertions(+) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/timer/ClockSelfSync.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/timer/ClockSelfSyncTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index d43307435..7bbc44409 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -575,6 +575,7 @@ public static String excludedBandsToCsv() { public static boolean autoUpdateGridFromGPS = false;//Use device GPS to keep Maidenhead grid current public static boolean disciplineClockFromGPS = false;//Discipline the app clock (UtcTimer.delay) from GPS satellite time (issue #373). Off by default — consensual. public static int gpsClockIntervalMinutes = 5;//How often to re-read GPS time for clock discipline. Clamped 1-30 by GpsClockUpdater. + public static boolean autoSyncClockFromDecodes = false;//Self-syncing clock: continuously trim UtcTimer.delay from the median decode DT (see ClockSelfSync). Inert while disciplineClockFromGPS is on. Off by default. //Runtime status for the Time Sync UI (not persisted): the offset the last GPS fix applied //to UtcTimer.delay. The last-sync *timestamp* is the retained value of mutableGpsClockSync //below, so there's no separate field for it. 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 c1f913e08..a3d8f4c53 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -120,6 +120,7 @@ import com.k1af.ft8af.rigs.Yaesu39Rig; import com.k1af.ft8af.rigs.YaesuDX10Rig; import com.k1af.ft8af.spectrum.SpectrumListener; +import com.k1af.ft8af.timer.ClockSelfSync; import com.k1af.ft8af.timer.OnUtcTimer; import com.k1af.ft8af.timer.UtcTimer; import com.k1af.ft8af.ui.ToastMessage; @@ -170,6 +171,12 @@ private void fileLog(String msg) { public final ArrayList ft8Messages = new ArrayList<>();//message list public UtcTimer utcTimer;//timer for sync-triggered actions. + // Self-syncing clock (opt-in): per-slot median-DT estimator that trims + // UtcTimer.delay from the band itself. Fed from afterDecode's fast pass; + // reset by the settings toggle / GPS discipline takeover. Public so + // TimeSyncSettings can reset it when the operator flips the toggles. + public final ClockSelfSync clockSelfSync = new ClockSelfSync(); + //public CallsignDatabase callsignDatabase = null;//callsign information database public DatabaseOpr databaseOpr;//configuration info and related data database @@ -667,6 +674,42 @@ public void afterDecode(long utc, float time_sec, int sequential // passes to avoid log spam (they re-report the same cycle). if (!isDeep) { fileLog(filtered.decodeLogLine(sequential)); + + // Self-syncing clock (opt-in): sample this slot's per-decode DTs + // from the fast pass only — it fires exactly once per slot with + // that pass's own (deduped, echo-filtered) messages. Deep/late + // passes re-deliver the same slot and are skipped by the isDeep + // gate; beginSlot() dedupes by slot utc as a second line of + // defense. GPS discipline owns the clock when enabled, so this + // stands down (and clears its state) rather than fight it. + if (GeneralVariables.autoSyncClockFromDecodes + && !GeneralVariables.disciplineClockFromGPS) { + float[] dtSamples = new float[messages.size()]; + for (int i = 0; i < messages.size(); i++) { + dtSamples[i] = messages.get(i).time_sec; + } + // onSlot is atomic: slot dedup/ordering + streak update under + // one lock, so concurrent adjacent-slot deliveries can't + // interleave (out-of-order slots are rejected inside). + Integer newDelay = + clockSelfSync.onSlot(utc, dtSamples, UtcTimer.delay); + if (newDelay != null) { + int oldDelay = UtcTimer.delay; + // Same three-way fan-out as TimeSyncSettings.apply(): + // live timer, in-memory config mirror, and DB. + UtcTimer.delay = newDelay; + GeneralVariables.manualTimeCorrectionMs = newDelay; + databaseOpr.writeConfig("timeCorrectionMs", + String.valueOf(newDelay), null); + fileLog("selfSync: applied clock correction " + + oldDelay + " -> " + newDelay + " ms (" + + dtSamples.length + " decodes, slot utc=" + utc + ")"); + } + } else { + // Feature off or GPS disciplining: drop any half-built + // confirmation streak so stale state can't act later. + clockSelfSync.reset(); + } } if (messages.size() == 0) { //nothing left after filtering own echoes diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 29fd5f6e8..49f0ea6a0 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2828,6 +2828,9 @@ protected Void doInBackground(Void... voids) { if (name.equalsIgnoreCase("disciplineClockFromGPS")) {//Discipline clock from GPS (issue #373) GeneralVariables.disciplineClockFromGPS = result.equals("1"); } + if (name.equalsIgnoreCase("autoSyncClockFromDecodes")) {//Self-syncing clock from decode DT medians (ClockSelfSync) + GeneralVariables.autoSyncClockFromDecodes = result.equals("1"); + } if (name.equalsIgnoreCase("gpsClockIntervalMin")) {//GPS discipline update interval (minutes) GeneralVariables.gpsClockIntervalMinutes = com.k1af.ft8af.location.GpsClockUpdater.parseIntervalMinutes(result); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/timer/ClockSelfSync.java b/ft8af/app/src/main/java/com/k1af/ft8af/timer/ClockSelfSync.java new file mode 100644 index 000000000..4e43d8116 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/timer/ClockSelfSync.java @@ -0,0 +1,202 @@ +package com.k1af.ft8af.timer; + +import com.k1af.ft8af.GeneralVariables; + +import java.util.Arrays; + +/** + * Self-syncing clock: estimates the local clock error from the per-decode time + * offsets (WSJT-style DT, seconds) of the stations we hear each slot, and decides + * when — and by how much — to trim the app's global clock offset + * ({@link UtcTimer#delay}). The band itself becomes the time source: no NTP or + * GPS needed. + * + *

Sign convention (matches {@code suggestedCorrectionMs} in + * {@code TimeCorrection.kt}): a positive median DT means decodes land late in our + * RX window — our clock is fast — so the correction subtracts from the + * current delay. Negative DT adds. The goal is to drive the measured DT toward 0. + * + *

Damping, not step-limiting. Each applied correction shifts the next + * slot's measured DT, so a full-step correction would ring. A proportional gain + * ({@link #GAIN}) halves the error per step instead. There is deliberately NO + * hard cap on step size: see the long comment in {@code GpsClockUpdater} (a step + * limiter shipped there once and locked in a bad baseline for a whole activation + * — proportional gain always converges, a step cap can refuse the truth forever). + * + *

Robustness per slot: a median (not mean) over the slot's DTs, MAD-based + * outlier rejection on top of it, a minimum surviving-sample count, a deadband + * matching the UI's "good clock" threshold, and two-consecutive-slot same-sign + * confirmation before any correction is applied. + * + *

Pure Java, no Android imports (the {@link GeneralVariables} references are + * {@code static final int} constant expressions, inlined at compile time), so + * this is unit-testable without Robolectric. Public entry points are + * synchronized: decode passes for adjacent slots can deliver concurrently. + */ +public class ClockSelfSync { + + /** Minimum surviving (post-outlier-rejection) decodes for a slot to count. */ + public static final int MIN_SLOT_SAMPLES = 4; + + /** + * |median DT| at/below this (seconds) is left alone. Matches + * {@code CLOCK_SYNC_GOOD_SEC} in {@code ui/components/ClockSync.kt} — the + * threshold the clock-health indicator already calls "good". + */ + public static final float DEADBAND_SEC = 0.30f; + + /** Proportional gain applied to the measured error (damps the feedback loop). */ + public static final float GAIN = 0.5f; + + /** Consecutive qualifying same-sign slots required before a correction is applied. */ + public static final int CONFIRM_SLOTS = 2; + + /** Absolute floor (seconds) for the MAD-based rejection threshold. */ + static final float MAD_FLOOR_SEC = 0.2f; + + /** Rejection threshold is max(floor, this multiple of the MAD). */ + static final float MAD_MULTIPLIER = 3f; + + // Confirmation streak: how many consecutive qualifying slots have agreed, and + // the sign (+1/-1) they agreed on. 0 sign = no streak. + private int streak = 0; + private int streakSign = 0; + + // Slot dedup: afterDecode fires several times per slot (fast, deep, late + // passes); only the first qualifying delivery per slot utc may be sampled. + private long lastProcessedUtc = Long.MIN_VALUE; + + /** + * Median of {@code a}; the mean of the two middle values for even lengths. + * An empty (or null) array yields 0 — callers gate on sample count anyway. + */ + public static float medianOf(float[] a) { + if (a == null || a.length == 0) { + return 0f; + } + float[] sorted = a.clone(); + Arrays.sort(sorted); + int mid = sorted.length / 2; + if (sorted.length % 2 == 1) { + return sorted[mid]; + } + return (sorted[mid - 1] + sorted[mid]) / 2f; + } + + /** + * MAD-based outlier rejection: samples farther than + * max({@link #MAD_FLOOR_SEC}, {@link #MAD_MULTIPLIER} x MAD) from the median + * are dropped. The floor keeps a tight cluster (MAD near 0) from rejecting + * everything but the exact median value. + */ + static float[] rejectOutliers(float[] dtSec) { + if (dtSec == null || dtSec.length == 0) { + return new float[0]; + } + float median = medianOf(dtSec); + float[] deviations = new float[dtSec.length]; + for (int i = 0; i < dtSec.length; i++) { + deviations[i] = Math.abs(dtSec[i] - median); + } + float mad = medianOf(deviations); + float threshold = Math.max(MAD_FLOOR_SEC, MAD_MULTIPLIER * mad); + int kept = 0; + float[] survivors = new float[dtSec.length]; + for (float dt : dtSec) { + if (Math.abs(dt - median) <= threshold) { + survivors[kept++] = dt; + } + } + return Arrays.copyOf(survivors, kept); + } + + /** Clamp to the shared manual-correction range (±5000 ms). */ + static int clampDelayMs(int ms) { + return Math.max(GeneralVariables.MANUAL_TIME_CORRECTION_MIN_MS, + Math.min(GeneralVariables.MANUAL_TIME_CORRECTION_MAX_MS, ms)); + } + + /** + * Mark the slot identified by {@code utc} as processed. Returns true exactly + * once per slot, and only for slots NEWER than the last processed one — + * decode threads for adjacent slots run concurrently, so a slow slot's + * delivery can arrive after its successor's; accepting it then would feed + * stale evidence into the confirmation streak. Monotonic rejection makes + * late stragglers a no-op. + */ + public synchronized boolean beginSlot(long utc) { + if (utc <= lastProcessedUtc) { + return false; + } + lastProcessedUtc = utc; + return true; + } + + /** + * Atomic per-slot entry point: slot dedup/ordering ({@link #beginSlot}) and + * the correction decision ({@link #onSlotDecodes}) under one lock, so two + * decode threads delivering different slots cannot interleave between the + * dedup check and the streak update. Production code must use this; the + * two-step methods stay public for targeted unit tests. + */ + public synchronized Integer onSlot(long utc, float[] dtSec, int currentDelayMs) { + if (!beginSlot(utc)) { + return null; + } + return onSlotDecodes(dtSec, currentDelayMs); + } + + /** + * Per-slot decision. Feeds one slot's decode DTs (seconds) through outlier + * rejection, the sample-count gate, the deadband, and the two-slot same-sign + * confirmation, and returns the NEW total clock delay (ms) to apply — or + * null for no action this slot. + * + * @param dtSec this slot's per-decode DTs (seconds), own-TX echoes + * already filtered out + * @param currentDelayMs the live {@link UtcTimer#delay} at decision time + * @return the new clamped delay to fan out, or null to do nothing + */ + public synchronized Integer onSlotDecodes(float[] dtSec, int currentDelayMs) { + float[] survivors = rejectOutliers(dtSec); + if (survivors.length < MIN_SLOT_SAMPLES) { + // Too little evidence — leave the confirmation state untouched so a + // sparse slot doesn't break up a real streak. + return null; + } + float median = medianOf(survivors); + if (Math.abs(median) <= DEADBAND_SEC) { + // Clock is healthy; a streak that was building was noise. + streak = 0; + streakSign = 0; + return null; + } + int sign = median > 0 ? 1 : -1; + if (sign != streakSign) { + // First qualifying slot in this direction (or a direction flip): + // start/restart the streak, act only if a later slot confirms. + streakSign = sign; + streak = 1; + } else { + streak++; + } + if (streak < CONFIRM_SLOTS) { + return null; + } + int newDelay = clampDelayMs( + currentDelayMs - Math.round(median * 1000f * GAIN)); + streak = 0; + streakSign = 0; + return newDelay; + } + + /** + * Clears the confirmation streak and the last-processed-slot tracking. + * Called when the feature is toggled off or GPS clock discipline takes over. + */ + public synchronized void reset() { + streak = 0; + streakSign = 0; + lastProcessedUtc = Long.MIN_VALUE; + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/SettingsRow.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/SettingsRow.kt index 96283a368..cd727f740 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/SettingsRow.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/SettingsRow.kt @@ -28,6 +28,7 @@ fun SettingsRow( onToggleChange: ((Boolean) -> Unit)? = null, showChevron: Boolean = false, onClick: (() -> Unit)? = null, + enabled: Boolean = true, ) { Row( modifier = modifier @@ -70,6 +71,7 @@ fun SettingsRow( Toggle( checked = toggle, onCheckedChange = onToggleChange, + enabled = enabled, ) } if (showChevron) { diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeSyncSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeSyncSettings.kt index 8171eaf47..a3f114405 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeSyncSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/TimeSyncSettings.kt @@ -63,6 +63,9 @@ fun TimeSyncSettings( // GPS clock discipline (issue #373). var disciplineFromGps by remember { mutableStateOf(GeneralVariables.disciplineClockFromGPS) } + + // Self-syncing clock: auto-trim the correction from decode DT medians. + var autoSyncFromDecodes by remember { mutableStateOf(GeneralVariables.autoSyncClockFromDecodes) } var gpsIntervalMin by remember { mutableIntStateOf(GeneralVariables.gpsClockIntervalMinutes) } // Re-read the status readout whenever a GPS fix disciplines the clock. The LiveData // retains its last posted timestamp, so seeding from .value shows a prior sync when the @@ -164,6 +167,36 @@ fun TimeSyncSettings( ) } + // ===================================================================== + // SELF-SYNCING CLOCK (auto-correct from decode DT medians) + // ===================================================================== + SettingsSection(title = stringResource(R.string.settings_selfsync_section)) { + GlassCard(modifier = Modifier.fillMaxWidth()) { + SettingsRow( + label = stringResource(R.string.settings_selfsync_toggle), + description = stringResource(R.string.settings_selfsync_toggle_desc), + toggle = autoSyncFromDecodes, + // Same lock-out as the manual controls: while GPS discipline owns + // the clock this estimator must not fight it, so the row is inert. + enabled = !disciplineFromGps, + onToggleChange = { checked -> + autoSyncFromDecodes = checked + GeneralVariables.autoSyncClockFromDecodes = checked + mainViewModel.databaseOpr.writeConfig( + "autoSyncClockFromDecodes", + if (checked) "1" else "0", + null, + ) + if (!checked) { + // Drop any half-built confirmation streak so re-enabling + // later starts from a clean slate. + mainViewModel.clockSelfSync.reset() + } + }, + ) + } + } + // ===================================================================== // SUGGESTION (from decode DT) // ===================================================================== @@ -237,6 +270,12 @@ fun TimeSyncSettings( mainViewModel.databaseOpr.writeConfig( "disciplineClockFromGPS", if (checked) "1" else "0", null, ) + if (checked) { + // GPS discipline takes over the clock; the self-sync + // estimator stands down — clear its streak so stale + // pre-GPS evidence can't act if GPS is later disabled. + mainViewModel.clockSelfSync.reset() + } GpsClockUpdater.refresh(context) }, ) diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index c042dbaf7..22827d305 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -576,6 +576,11 @@ Apply suggested correction GPS clock discipline is controlling the clock — turn it off below to adjust manually. + + Self-Syncing Clock + Auto-sync clock from decodes + Continuously trim the clock using the median DT of received stations — the band itself becomes the time source. No internet or GPS needed. Unavailable while GPS clock discipline is on. + GPS Clock Discipline Discipline clock from GPS diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java index b96c1e67f..a2d5db073 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java @@ -54,6 +54,7 @@ public class DatabaseOprConfigHydrationTest { private int origAlcTargetLow; private boolean origDeepDecodeMode; private boolean origKeepScreenOn; + private boolean origAutoSyncClockFromDecodes; @Before public void setUp() { @@ -71,6 +72,7 @@ public void setUp() { origAlcTargetLow = GeneralVariables.alcTargetLow; origDeepDecodeMode = GeneralVariables.deepDecodeMode; origKeepScreenOn = GeneralVariables.keepScreenOn; + origAutoSyncClockFromDecodes = GeneralVariables.autoSyncClockFromDecodes; opr = new DatabaseOpr(ApplicationProvider.getApplicationContext(), null, null, 18); } @@ -94,6 +96,7 @@ public void tearDown() { GeneralVariables.alcTargetLow = origAlcTargetLow; GeneralVariables.deepDecodeMode = origDeepDecodeMode; GeneralVariables.keepScreenOn = origKeepScreenOn; + GeneralVariables.autoSyncClockFromDecodes = origAutoSyncClockFromDecodes; } } @@ -284,6 +287,35 @@ public void keepScreenOn_absentFromConfigKeepsThePreviousBehaviour() { assertThat(GeneralVariables.keepScreenOn).isTrue(); } + // ---- self-syncing clock (ClockSelfSync) ----------------------------------- + // New "autoSyncClockFromDecodes" key: the classic bug this guards against is + // adding the field + writeConfig on toggle but forgetting the hydration arm, + // which silently reverts the setting to off on every relaunch. + + @Test + public void autoSyncClockFromDecodes_hydratesOnAndOff() { + GeneralVariables.autoSyncClockFromDecodes = false; + Map config = new LinkedHashMap<>(); + config.put("autoSyncClockFromDecodes", "1"); + opr.writeConfigSync(config); + hydrate(); + assertThat(GeneralVariables.autoSyncClockFromDecodes).isTrue(); + + GeneralVariables.autoSyncClockFromDecodes = true; + config.put("autoSyncClockFromDecodes", "0"); + opr.writeConfigSync(config); + hydrate(); + assertThat(GeneralVariables.autoSyncClockFromDecodes).isFalse(); + } + + @Test + public void autoSyncClockFromDecodes_absentRowLeavesDefaultOff() { + GeneralVariables.autoSyncClockFromDecodes = false; + // No row written for the key at all (fresh install / pre-feature backup). + hydrate(); + assertThat(GeneralVariables.autoSyncClockFromDecodes).isFalse(); + } + @Test public void powerToggles_nonBooleanValueReadsAsOff() { // Hydration compares against "1", so anything else is off rather than a diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/timer/ClockSelfSyncTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/timer/ClockSelfSyncTest.java new file mode 100644 index 000000000..55d7c57d8 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/timer/ClockSelfSyncTest.java @@ -0,0 +1,303 @@ +package com.k1af.ft8af.timer; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +import java.util.Arrays; + +/** + * Pure unit tests for {@link ClockSelfSync} — the self-syncing clock estimator + * (median + MAD rejection + deadband + two-slot confirmation + proportional + * gain). No Android types are touched (the GeneralVariables clamp bounds are + * compile-time constants), so no Robolectric runner is needed. + * + *

Sign convention under test matches {@code suggestedCorrectionMs} in + * {@code TimeCorrection.kt}: positive DT (decodes late in our window = clock + * fast) must DECREASE the delay. + */ +public class ClockSelfSyncTest { + + // ------------------------------------------------------------------ + // medianOf + // ------------------------------------------------------------------ + + @Test + public void median_oddLength_returnsMiddleValue() { + assertThat(ClockSelfSync.medianOf(new float[]{0.3f, 0.1f, 0.2f})).isEqualTo(0.2f); + assertThat(ClockSelfSync.medianOf(new float[]{5f})).isEqualTo(5f); + } + + @Test + public void median_evenLength_returnsMeanOfMiddleTwo() { + assertThat(ClockSelfSync.medianOf(new float[]{0.1f, 0.2f, 0.3f, 0.4f})) + .isWithin(1e-6f).of(0.25f); + assertThat(ClockSelfSync.medianOf(new float[]{2f, 1f})).isWithin(1e-6f).of(1.5f); + } + + @Test + public void median_empty_isZero() { + assertThat(ClockSelfSync.medianOf(new float[0])).isEqualTo(0f); + assertThat(ClockSelfSync.medianOf(null)).isEqualTo(0f); + } + + @Test + public void median_doesNotMutateInput() { + float[] input = {0.3f, 0.1f, 0.2f}; + ClockSelfSync.medianOf(input); + assertThat(input).usingExactEquality().containsExactly(0.3f, 0.1f, 0.2f).inOrder(); + } + + // ------------------------------------------------------------------ + // MAD-based outlier rejection + // ------------------------------------------------------------------ + + @Test + public void rejectOutliers_dropsFarOutlier_keepsTightCluster() { + // Cluster near 0.5 s plus one wild -3 s decode (e.g. a wrong-slot signal). + float[] dt = {0.48f, 0.50f, 0.52f, 0.49f, -3.0f}; + float[] survivors = ClockSelfSync.rejectOutliers(dt); + assertThat(survivors.length).isEqualTo(4); + for (float s : survivors) { + assertThat(s).isGreaterThan(0.4f); + } + } + + @Test + public void rejectOutliers_identicalSamples_allSurviveViaMadFloor() { + // MAD == 0 for identical values; without the 0.2 s floor everything but + // exact matches of the median would be rejected on real (jittery) data. + float[] dt = {0.7f, 0.7f, 0.7f, 0.7f}; + assertThat(ClockSelfSync.rejectOutliers(dt).length).isEqualTo(4); + } + + @Test + public void rejectOutliers_jitterWithinFloor_allSurvive() { + // Spread of ±0.15 s around the median is inside the 0.2 s floor even + // though 3xMAD alone would be tighter. + float[] dt = {0.55f, 0.60f, 0.65f, 0.70f, 0.75f}; + assertThat(ClockSelfSync.rejectOutliers(dt).length).isEqualTo(5); + } + + @Test + public void rejectOutliers_empty_returnsEmpty() { + assertThat(ClockSelfSync.rejectOutliers(new float[0]).length).isEqualTo(0); + assertThat(ClockSelfSync.rejectOutliers(null).length).isEqualTo(0); + } + + // ------------------------------------------------------------------ + // onSlotDecodes: gates + // ------------------------------------------------------------------ + + /** A slot of {@code n} identical DTs (survives rejection unchanged). */ + private static float[] slot(float dtSec, int n) { + float[] a = new float[n]; + Arrays.fill(a, dtSec); + return a; + } + + @Test + public void fewerThanMinSamples_returnsNull() { + ClockSelfSync sync = new ClockSelfSync(); + assertThat(sync.onSlotDecodes(slot(1.0f, ClockSelfSync.MIN_SLOT_SAMPLES - 1), 0)) + .isNull(); + assertThat(sync.onSlotDecodes(new float[0], 0)).isNull(); + } + + @Test + public void sparseSlot_doesNotTouchConfirmationState() { + ClockSelfSync sync = new ClockSelfSync(); + // Slot 1: qualifying out-of-deadband slot starts the streak. + assertThat(sync.onSlotDecodes(slot(1.0f, 4), 0)).isNull(); + // Slot 2: too few samples — must NOT reset the streak. + assertThat(sync.onSlotDecodes(slot(1.0f, 2), 0)).isNull(); + // Slot 3: same sign again — confirmation completes, correction applied. + assertThat(sync.onSlotDecodes(slot(1.0f, 4), 0)).isNotNull(); + } + + @Test + public void medianInsideDeadband_returnsNullAndResetsStreak() { + ClockSelfSync sync = new ClockSelfSync(); + // Start a streak with an out-of-deadband slot... + assertThat(sync.onSlotDecodes(slot(1.0f, 4), 0)).isNull(); + // ...then a healthy slot (|median| <= 0.30) clears it... + assertThat(sync.onSlotDecodes(slot(0.25f, 4), 0)).isNull(); + // ...so the next qualifying slot is a FIRST slot again (null), not a second. + assertThat(sync.onSlotDecodes(slot(1.0f, 4), 0)).isNull(); + // And only its confirmation acts. + assertThat(sync.onSlotDecodes(slot(1.0f, 4), 0)).isNotNull(); + } + + @Test + public void deadbandBoundary_isInclusive() { + ClockSelfSync sync = new ClockSelfSync(); + // Exactly 0.30 s is still "good" (matches CLOCK_SYNC_GOOD_SEC semantics). + assertThat(sync.onSlotDecodes(slot(ClockSelfSync.DEADBAND_SEC, 4), 0)).isNull(); + assertThat(sync.onSlotDecodes(slot(ClockSelfSync.DEADBAND_SEC, 4), 0)).isNull(); + assertThat(sync.onSlotDecodes(slot(-ClockSelfSync.DEADBAND_SEC, 4), 0)).isNull(); + } + + // ------------------------------------------------------------------ + // onSlotDecodes: hysteresis + // ------------------------------------------------------------------ + + @Test + public void firstQualifyingSlot_isNull_secondSameSign_steps() { + ClockSelfSync sync = new ClockSelfSync(); + assertThat(sync.onSlotDecodes(slot(1.0f, 5), 0)).isNull(); + Integer applied = sync.onSlotDecodes(slot(1.0f, 5), 0); + assertThat(applied).isNotNull(); + // GAIN 0.5: 1.0 s median -> -500 ms step from a 0 baseline. + assertThat(applied).isEqualTo(-500); + } + + @Test + public void oppositeSignSecondSlot_restartsStreak() { + ClockSelfSync sync = new ClockSelfSync(); + assertThat(sync.onSlotDecodes(slot(1.0f, 4), 0)).isNull(); // +streak = 1 + assertThat(sync.onSlotDecodes(slot(-1.0f, 4), 0)).isNull(); // flip: -streak = 1 + // Confirming the NEW (negative) direction acts; sign is the negative one's. + Integer applied = sync.onSlotDecodes(slot(-1.0f, 4), 0); + assertThat(applied).isEqualTo(500); + } + + @Test + public void afterApplying_streakResets_soNextCorrectionNeedsTwoMoreSlots() { + ClockSelfSync sync = new ClockSelfSync(); + assertThat(sync.onSlotDecodes(slot(1.0f, 4), 0)).isNull(); + assertThat(sync.onSlotDecodes(slot(1.0f, 4), 0)).isNotNull(); // applied + // Immediately after an apply, one more qualifying slot is not enough. + assertThat(sync.onSlotDecodes(slot(1.0f, 4), -500)).isNull(); + assertThat(sync.onSlotDecodes(slot(1.0f, 4), -500)).isNotNull(); + } + + // ------------------------------------------------------------------ + // onSlotDecodes: step math, sign convention, clamp + // ------------------------------------------------------------------ + + @Test + public void stepMath_halvesTheMeasuredError_matchingSuggestedCorrectionSign() { + ClockSelfSync sync = new ClockSelfSync(); + // Positive DT (clock fast) => delay DECREASES — same direction as + // suggestedCorrectionMs(currentDelayMs, avgDtSec), at half magnitude. + sync.onSlotDecodes(slot(0.8f, 4), 200); + assertThat(sync.onSlotDecodes(slot(0.8f, 4), 200)) + .isEqualTo(200 - Math.round(0.8f * 1000f * ClockSelfSync.GAIN)); // = -200 + // Negative DT (clock slow) => delay INCREASES. + sync.reset(); + sync.onSlotDecodes(slot(-0.6f, 4), -100); + assertThat(sync.onSlotDecodes(slot(-0.6f, 4), -100)).isEqualTo(-100 + 300); + } + + @Test + public void stepUsesMedianOfSurvivors_notTheRawMean() { + ClockSelfSync sync = new ClockSelfSync(); + // One -3 s outlier among a 1.0 s cluster: the mean would be dragged to + // 0.2 s (inside the deadband!), but MAD rejection + median stays at 1.0 s. + float[] withOutlier = {1.0f, 1.0f, 1.0f, 1.0f, -3.0f}; + assertThat(sync.onSlotDecodes(withOutlier, 0)).isNull(); + assertThat(sync.onSlotDecodes(withOutlier, 0)).isEqualTo(-500); + } + + @Test + public void newDelay_clampsAtPlusMinus5000() { + ClockSelfSync sync = new ClockSelfSync(); + sync.onSlotDecodes(slot(4.0f, 4), -4000); + // -4000 - 2000 = -6000 -> clamped to -5000. + assertThat(sync.onSlotDecodes(slot(4.0f, 4), -4000)).isEqualTo(-5000); + sync.reset(); + sync.onSlotDecodes(slot(-4.0f, 4), 4000); + // 4000 + 2000 = 6000 -> clamped to +5000. + assertThat(sync.onSlotDecodes(slot(-4.0f, 4), 4000)).isEqualTo(5000); + } + + // ------------------------------------------------------------------ + // Convergence property + // ------------------------------------------------------------------ + + @Test + public void convergence_from1500msOff_reachesDeadbandMonotonically() { + ClockSelfSync sync = new ClockSelfSync(); + // trueErrorMs: how far the app clock is from the band. Each slot's + // measured DT is exactly that error (consistent signals); each applied + // correction shifts the clock and therefore the next slot's DT. + int delayMs = 0; + float trueErrorSec = 1.5f; // clock 1500 ms fast: decodes land at +1.5 s DT + float prevAbs = Math.abs(trueErrorSec); + int slots = 0; + while (Math.abs(trueErrorSec) > ClockSelfSync.DEADBAND_SEC && slots < 20) { + slots++; + Integer newDelay = sync.onSlotDecodes(slot(trueErrorSec, 6), delayMs); + if (newDelay != null) { + // The applied correction moves the clock; the band's apparent DT + // shrinks by the same amount. + trueErrorSec -= (delayMs - newDelay) / 1000f; + delayMs = newDelay; + // Monotonic approach: never overshoots into a larger |error|. + assertThat(Math.abs(trueErrorSec)).isLessThan(prevAbs); + prevAbs = Math.abs(trueErrorSec); + } + } + // 1500 -> 750 -> 375 -> 187.5 ms: three corrections, two slots each. + assertThat(Math.abs(trueErrorSec)).isAtMost(ClockSelfSync.DEADBAND_SEC); + assertThat(slots).isAtMost(6); + } + + // ------------------------------------------------------------------ + // beginSlot / reset + // ------------------------------------------------------------------ + + @Test + public void beginSlot_trueOncePerUtc() { + ClockSelfSync sync = new ClockSelfSync(); + assertThat(sync.beginSlot(15_000L)).isTrue(); + assertThat(sync.beginSlot(15_000L)).isFalse(); // same slot redelivered + assertThat(sync.beginSlot(30_000L)).isTrue(); // next slot + } + + @Test + public void beginSlot_rejectsOlderSlotArrivingLate() { + // Adjacent-slot decode threads run concurrently: a slow slot's delivery + // can land AFTER its successor's. Monotonic dedup must reject it. + ClockSelfSync sync = new ClockSelfSync(); + assertThat(sync.beginSlot(30_000L)).isTrue(); + assertThat(sync.beginSlot(15_000L)).isFalse(); // straggler from the past + assertThat(sync.beginSlot(45_000L)).isTrue(); + } + + // ------------------------------------------------------------------ + // onSlot — the atomic production entry (dedup + decision in one lock) + // ------------------------------------------------------------------ + + @Test + public void onSlot_redeliveredSlot_isIgnoredEvenWithQualifyingSamples() { + ClockSelfSync sync = new ClockSelfSync(); + assertThat(sync.onSlot(15_000L, slot(1.0f, 4), 0)).isNull(); // streak = 1 + // Redelivery of the same slot must not advance the streak to a step. + assertThat(sync.onSlot(15_000L, slot(1.0f, 4), 0)).isNull(); + // The genuine second slot confirms and steps: -median*1000*GAIN. + assertThat(sync.onSlot(30_000L, slot(1.0f, 4), 0)).isEqualTo(-500); + } + + @Test + public void onSlot_lateOlderSlot_cannotAffectStreak() { + ClockSelfSync sync = new ClockSelfSync(); + assertThat(sync.onSlot(30_000L, slot(1.0f, 4), 0)).isNull(); // streak = 1 + // A stale slot from the past, even with opposite-sign evidence that + // would reset the streak, is rejected by the monotonic dedup. + assertThat(sync.onSlot(15_000L, slot(-1.0f, 4), 0)).isNull(); + assertThat(sync.onSlot(45_000L, slot(1.0f, 4), 0)).isEqualTo(-500); + } + + @Test + public void reset_clearsStreakAndSlotTracking() { + ClockSelfSync sync = new ClockSelfSync(); + assertThat(sync.beginSlot(15_000L)).isTrue(); + assertThat(sync.onSlotDecodes(slot(1.0f, 4), 0)).isNull(); // streak = 1 + sync.reset(); + // Streak gone: the next qualifying slot is a first slot again. + assertThat(sync.onSlotDecodes(slot(1.0f, 4), 0)).isNull(); + // Slot tracking gone: the same utc is processable again. + assertThat(sync.beginSlot(15_000L)).isTrue(); + } +} From d01be83b0b8301bfd8a5b2ad1387b397d56866e6 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sun, 2 Aug 2026 10:51:30 -0500 Subject: [PATCH 113/113] Voice assistant v1: spoken event announcements + push-to-talk voice commands (#721) * Voice assistant v1: spoken event announcements + push-to-talk commands Opt-in hands-free layer for mobile/POTA passenger operation and accessibility. Announcements (TTS, per-event toggles) ride the existing DxAlertNotifier decode/QSO hooks: station calling you (with SNR), QSO logged, new-DXCC CQ, new-prefix CQ. Callsigns are spelled letter-by-letter so engines don't read them as words. Two hard audio-safety rules from the TX pipeline docs are enforced: - TTS never plays while the rig is keyed (it would be mixed into the TX audio and transmitted): the announcer refuses to start an utterance during TX, and a mutableIsTransmitting observer hard-stops in-flight speech at key-up. Suppressed announcements don't burn their dedup key. - The push-to-talk mic button is disabled whenever FT8 RX holds an Android audio-capture session (phone mic, or Android-routed USB input) - SpeechRecognizer would fight our capture. Direct-libusb USB and LAN audio leave it available. Commands are a small offline keyword grammar (answer / call CQ / stop / skip / log it) mapped onto the same entry points the UI buttons use (callStation, userResetToCQ + setActivated, forceLogAndMoveOn), with a pure newest-caller selector for "answer" and a spoken echo of each action. New Voice Assistant settings category (5 toggles, config-table persistence with hydration arms); entries for RecognitionService and TTS_SERVICE. 59 new unit tests, all pure JVM. Co-Authored-By: Claude Fable 5 * Make the voice-command mic button react to its settings toggle live Device smoke test caught the button only appearing/disappearing after an app restart: the Composable read GeneralVariables.voiceCommandsEnabled as a plain static, which nothing invalidates. Add the house-pattern LiveData mirror (mutableVoiceCommandsEnabled) updated by the settings toggle and config hydration, and observe it from VoiceCommandButton. Verified on hardware: toggling now shows/hides the button immediately both ways. Co-Authored-By: Claude Fable 5 * Address Copilot review on the voice assistant (PR #721) - Suppress the recognizer-error toast for ERROR_CLIENT: it's what cancel() (a deliberate second tap) emits, so toasting it made a user-initiated cancel look like a failure. Mapping extracted to the pure voiceErrorToastRes() and covered by tests. - VoiceAnnouncementDecisions.norm() now uppercases with Locale.ROOT so dedup keys are stable regardless of device locale (Turkish dotted-I regression test added). - Mic-gate strings no longer claim only the "phone mic" is the blocker - the gate also covers Android-routed USB input, and the wording now says so. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- ft8af/app/src/main/AndroidManifest.xml | 12 + .../java/com/k1af/ft8af/GeneralVariables.java | 17 + .../java/com/k1af/ft8af/MainViewModel.java | 66 ++++ .../com/k1af/ft8af/alert/DxAlertNotifier.java | 100 +++++- .../com/k1af/ft8af/database/DatabaseOpr.java | 19 ++ .../voice/VoiceAnnouncementDecisions.java | 94 ++++++ .../com/k1af/ft8af/voice/VoiceAnnouncer.java | 169 ++++++++++ .../k1af/ft8af/voice/VoiceAnswerSelector.java | 74 +++++ .../k1af/ft8af/voice/VoiceCommandParser.java | 80 +++++ .../com/k1af/ft8af/voice/VoicePhrases.java | 98 ++++++ .../java/com/k1af/ft8af/wave/HamRecorder.java | 24 ++ .../java/com/k1af/ft8af/wave/MicRecorder.java | 11 + .../kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt | 13 + .../ks3ckc/ft8af/ui/components/FT8AFIcons.kt | 36 ++ .../ft8af/ui/components/VoiceCommandButton.kt | 312 ++++++++++++++++++ .../ft8af/ui/settings/SettingsScreen.kt | 10 + .../ks3ckc/ft8af/ui/settings/VoiceSettings.kt | 136 ++++++++ .../src/main/res/values/strings_compose.xml | 25 ++ .../DatabaseOprConfigHydrationTest.java | 53 +++ .../voice/VoiceAnnouncementDecisionsTest.java | 172 ++++++++++ .../ft8af/voice/VoiceAnswerSelectorTest.java | 86 +++++ .../ft8af/voice/VoiceCommandParserTest.java | 120 +++++++ .../k1af/ft8af/voice/VoicePhrasesTest.java | 100 ++++++ .../ft8af/wave/HamRecorderMicGateTest.java | 35 ++ .../components/VoiceCommandButtonLogicTest.kt | 92 ++++++ 25 files changed, 1953 insertions(+), 1 deletion(-) create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisions.java create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncer.java create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnswerSelector.java create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceCommandParser.java create mode 100644 ft8af/app/src/main/java/com/k1af/ft8af/voice/VoicePhrases.java create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButton.kt create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/VoiceSettings.kt create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisionsTest.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnswerSelectorTest.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceCommandParserTest.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/voice/VoicePhrasesTest.java create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/wave/HamRecorderMicGateTest.java create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButtonLogicTest.kt diff --git a/ft8af/app/src/main/AndroidManifest.xml b/ft8af/app/src/main/AndroidManifest.xml index b42e731d6..4a3879e94 100644 --- a/ft8af/app/src/main/AndroidManifest.xml +++ b/ft8af/app/src/main/AndroidManifest.xml @@ -33,6 +33,18 @@ GPS breadcrumbs coming while the phone sits in a cradle with the screen off. --> + + + + + + + + + + mutableVoiceCommandsEnabled = + new MutableLiveData<>(false); + // Geographic continent-directed CQ tokens — matched against myContinent. private static final java.util.Set CONTINENT_CODES = new java.util.HashSet<>(java.util.Arrays.asList("NA", "SA", "EU", "AF", "AS", "OC", "AN")); 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 a3d8f4c53..3ff38bbb5 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -1058,6 +1058,25 @@ public void doOnAfterQueryCallsignLocation(CallsignInfo callsignInfo) { meterProtectionController.setTransmitSignal(ft8TransmitSignal); ft8TransmitSignal.setMeterProtectionController(meterProtectionController); + // Voice assistant: TTS must never leak into a transmission (it would be + // mixed into the rig audio and go out over the air — see the TX audio + // hazards in CLAUDE.md). Two layers: the announcer refuses to start an + // utterance while isTransmitting(), and this observer hard-stops any + // in-flight speech the instant TX begins. observeForever is safe here: + // the ViewModel constructor runs on the main thread (setValue above), + // and both objects live for the whole process. + { + final com.k1af.ft8af.voice.VoiceAnnouncer announcer = + dxAlertNotifier.getVoiceAnnouncer(); + announcer.setTransmitGate( + () -> ft8TransmitSignal != null && ft8TransmitSignal.isTransmitting()); + ft8TransmitSignal.mutableIsTransmitting.observeForever(transmitting -> { + if (Boolean.TRUE.equals(transmitting)) { + announcer.stopNow(); + } + }); + } + //bring up the WSJT-X UDP interface (status provider + inbound request handlers) setupWsjtxUdp(); @@ -1325,6 +1344,53 @@ public void callStation(Ft8Message message) { ft8TransmitSignal.transmitNow(); } + // --- Voice assistant -------------------------------------------------------- + + /** + * Whether FT8 RX currently holds an Android audio-capture session (system + * mic or Android-routed USB input). The voice-command push-to-talk button + * is disabled while true: a SpeechRecognizer would fight our capture under + * Android's concurrent-capture rules. False for direct-libusb USB audio + * and LAN audio sources, where the capture stack is free. + */ + public boolean isPhoneMicInUse() { + return hamRecorder != null && hamRecorder.isPhoneMicInUse(); + } + + /** The voice announcer (TTS), for the command button's spoken echo. */ + public com.k1af.ft8af.voice.VoiceAnnouncer getVoiceAnnouncer() { + return dxAlertNotifier.getVoiceAnnouncer(); + } + + /** + * "Answer" voice command: call the best current candidate — the newest + * decode addressed to me, else the caller-queue head's newest decode + * (selection logic in {@link com.k1af.ft8af.voice.VoiceAnswerSelector}). + * + * @return the callsign being answered, or null when there was no candidate + * (the spoken/toast feedback branches on this) + */ + public String voiceAnswerBestCaller() { + if (ft8TransmitSignal == null) return null; + ArrayList snapshot; + synchronized (ft8Messages) { + snapshot = new ArrayList<>(ft8Messages); + } + ArrayList queue = + ft8TransmitSignal.mutableCallerQueue.getValue(); + String queueHead = (queue != null && !queue.isEmpty()) ? queue.get(0).callsign : null; + + Ft8Message target = com.k1af.ft8af.voice.VoiceAnswerSelector.pick( + snapshot, + m -> GeneralVariables.checkIsMyCallsign(m.getCallsignTo()), + Ft8Message::getCallsignFrom, + queueHead); + if (target == null) return null; + // callStation guards against TX-in-progress, empty sender, and own call. + callStation(target); + return target.getCallsignFrom(); + } + // --- WSJT-X UDP interface ------------------------------------------------- /** diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java b/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java index 5b765deef..b7b36a326 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java @@ -17,7 +17,11 @@ import com.k1af.ft8af.Ft8Message; import com.k1af.ft8af.GeneralVariables; import com.k1af.ft8af.R; +import com.k1af.ft8af.callsign.WpxPrefix; import com.k1af.ft8af.log.QSLRecord; +import com.k1af.ft8af.voice.VoiceAnnouncementDecisions; +import com.k1af.ft8af.voice.VoiceAnnouncer; +import com.k1af.ft8af.voice.VoicePhrases; import java.util.Collections; import java.util.List; @@ -54,12 +58,22 @@ public class DxAlertNotifier { private final Context appContext; // Already-alerted keys this session (namespaced: "DXCC:"/"STATE:"/"CQREPLY:"/"QSO:"). private final Set alerted = Collections.newSetFromMap(new ConcurrentHashMap<>()); + // Voice assistant: spoken announcements ride the same decode/QSO hooks as the + // notification alerts, so the announcer is co-located here. TTS init is lazy + // inside the announcer — nothing spins up unless a voice toggle is enabled. + private final VoiceAnnouncer voiceAnnouncer; public DxAlertNotifier(Context context) { this.appContext = context != null ? context.getApplicationContext() : null; + this.voiceAnnouncer = new VoiceAnnouncer(this.appContext); createChannels(); } + /** The announcer, for TX-mute wiring (MainViewModel) and command echo (UI). */ + public VoiceAnnouncer getVoiceAnnouncer() { + return voiceAnnouncer; + } + private void createChannels() { if (appContext == null) return; if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; @@ -96,8 +110,13 @@ private void createHighChannel(NotificationManager nm, String id, String name, S */ public void processDecodes(List messages) { if (appContext == null || messages == null) return; + boolean anyVoice = VoiceAnnouncementDecisions.anyDecodeAnnounceEnabled( + GeneralVariables.voiceAnnounceCalling, + GeneralVariables.voiceAnnounceNewDxcc, + GeneralVariables.voiceAnnounceNewPrefix); if (!GeneralVariables.alertNewDxcc && !GeneralVariables.alertNewState - && !GeneralVariables.alertOnCqReply && !GeneralVariables.hasWatchCallsigns()) { + && !GeneralVariables.alertOnCqReply && !GeneralVariables.hasWatchCallsigns() + && !anyVoice) { return; } @@ -130,6 +149,11 @@ public void processDecodes(List messages) { cqReplyBody(msg), msg.getCallsignFrom(), msg.band); } + // Voice assistant: spoken announcement, independent toggles from the + // notification alerts. Runs before the CQ-only gate because the + // calling-me announcement (like the CQ-reply alert) isn't a CQ. + maybeAnnounceVoice(msg, addressedToMe); + // Needed-DX alerts apply to CQ broadcasts only. if (!msg.checkIsCQ()) continue; @@ -146,9 +170,70 @@ public void processDecodes(List messages) { } } + /** + * Voice-assistant arm of {@link #processDecodes}: decide + dedup + speak. + * Blocked messages never reach here (the caller {@code continue}s on them), + * and the dedup claim is gated on canSpeakNow() so a station first decoded + * during a transmission isn't silenced for the whole session. + */ + private void maybeAnnounceVoice(Ft8Message msg, boolean addressedToMe) { + boolean isCq = msg.checkIsCQ(); + + // New-prefix predicate, only computed when it could matter (mirrors the + // Kotlin isNewPrefixStation logic: WpxPrefix + checkQSLPrefix). + String prefix = null; + boolean fromNewPrefix = false; + if (GeneralVariables.voiceAnnounceNewPrefix && isCq) { + prefix = WpxPrefix.of(msg.getCallsignFrom()); + fromNewPrefix = prefix != null && !GeneralVariables.checkQSLPrefix(prefix); + } + + VoiceAnnouncementDecisions.Kind kind = VoiceAnnouncementDecisions.decide( + GeneralVariables.voiceAnnounceCalling, + GeneralVariables.voiceAnnounceNewDxcc, + GeneralVariables.voiceAnnounceNewPrefix, + addressedToMe, isCq, msg.fromDxcc, fromNewPrefix, + false /* blocked messages already filtered by the caller */); + if (kind == null) return; + + String key; + String phrase; + switch (kind) { + case CALLING_ME: + key = VoiceAnnouncementDecisions.callingMeKey(msg.getCallsignFrom()); + phrase = VoicePhrases.callingYou(msg.getCallsignFrom(), msg.snr); + break; + case NEW_DXCC: + // Same fallback as the notification arm: country name when + // resolved, else the (spelled) callsign. + boolean noCountry = msg.fromWhere == null || msg.fromWhere.isEmpty(); + String country = noCountry ? msg.getCallsignFrom() : msg.fromWhere; + key = VoiceAnnouncementDecisions.newDxccKey(country); + phrase = VoicePhrases.newCountry( + noCountry ? VoicePhrases.spellCallsign(country) : country); + break; + default: // NEW_PREFIX + key = VoiceAnnouncementDecisions.newPrefixKey(prefix); + phrase = VoicePhrases.newPrefix(prefix); + break; + } + + if (!VoiceAnnouncementDecisions.claim( + voiceAnnouncer.spokenKeys(), key, voiceAnnouncer.canSpeakNow())) { + return; + } + GeneralVariables.fileLog("VOICE announce key=[" + key + "] " + phrase); + voiceAnnouncer.speak(phrase, key); + } + /** Fire a notification when a QSO has just been logged, if the user enabled it. */ public void notifyQsoComplete(QSLRecord qslRecord) { if (appContext == null || qslRecord == null) return; + + // Voice announcement first — its toggle is independent of the + // notification toggle below. + announceQsoCompleteVoice(qslRecord); + if (!GeneralVariables.alertOnQsoComplete) return; String call = qslRecord.getToCallsign(); @@ -168,6 +253,19 @@ public void notifyQsoComplete(QSLRecord qslRecord) { body.toString(), call, qslRecord.getBandFreq()); } + /** "QSO with K 1 A B C logged" — once per logged contact, opt-in. */ + private void announceQsoCompleteVoice(QSLRecord qslRecord) { + if (!GeneralVariables.voiceAnnounceQsoComplete) return; + String call = qslRecord.getToCallsign(); + String key = VoiceAnnouncementDecisions.qsoCompleteKey(call, qslRecord.getEndTime()); + if (!VoiceAnnouncementDecisions.claim( + voiceAnnouncer.spokenKeys(), key, voiceAnnouncer.canSpeakNow())) { + return; + } + GeneralVariables.fileLog("VOICE announce key=[" + key + "]"); + voiceAnnouncer.speak(VoicePhrases.qsoLogged(call), key); + } + static String defaultBody(Ft8Message msg) { StringBuilder body = new StringBuilder(msg.getCallsignFrom()); if (msg.maidenGrid != null && !msg.maidenGrid.isEmpty()) { diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 49f0ea6a0..a01b8de38 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2996,6 +2996,25 @@ protected Void doInBackground(Void... voids) { if (name.equalsIgnoreCase("alertOnQsoComplete")) {//Alert when a QSO completes GeneralVariables.alertOnQsoComplete = result.equals("1"); } + if (name.equalsIgnoreCase("voiceAnnounceCalling")) {//Voice: announce station calling me + GeneralVariables.voiceAnnounceCalling = result.equals("1"); + } + if (name.equalsIgnoreCase("voiceAnnounceQsoComplete")) {//Voice: announce QSO logged + GeneralVariables.voiceAnnounceQsoComplete = result.equals("1"); + } + if (name.equalsIgnoreCase("voiceAnnounceNewDxcc")) {//Voice: announce new-DXCC CQ + GeneralVariables.voiceAnnounceNewDxcc = result.equals("1"); + } + if (name.equalsIgnoreCase("voiceAnnounceNewPrefix")) {//Voice: announce new-prefix CQ + GeneralVariables.voiceAnnounceNewPrefix = result.equals("1"); + } + if (name.equalsIgnoreCase("voiceCommandsEnabled")) {//Voice: push-to-talk command button + GeneralVariables.voiceCommandsEnabled = result.equals("1"); + // Hydration runs on a worker thread; postValue keeps the + // observable mirror (main-screen mic button) in sync. + GeneralVariables.mutableVoiceCommandsEnabled + .postValue(GeneralVariables.voiceCommandsEnabled); + } if (name.equalsIgnoreCase("flexMaxRfPower")) {//Flex max RF power GeneralVariables.flexMaxRfPower = parseConfigInt(result, 10); } diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisions.java b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisions.java new file mode 100644 index 000000000..0393eb7f9 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisions.java @@ -0,0 +1,94 @@ +package com.k1af.ft8af.voice; + +import java.util.Locale; +import java.util.Set; + +/** + * Pure, Android-free decision + dedup logic for spoken announcements (the + * voice-assistant counterpart of {@code alert/AlertDecisions}). The announcer + * itself touches TextToSpeech and can't be unit-tested; every branch here can. + * + *

Priority when one decode qualifies in several categories: a station + * calling ME beats everything (it needs action now), then new DXCC, then new + * prefix. New-DXCC / new-prefix announcements apply to CQ broadcasts only — + * same rule as the needed-DX notification alerts. + * + *

Dedup keys are namespaced strings collected in a per-session set so a + * station calling every cycle (and every decode pass within a cycle — early, + * late, deep passes all funnel through processDecodes) is announced once. + */ +public final class VoiceAnnouncementDecisions { + private VoiceAnnouncementDecisions() {} + + public enum Kind { CALLING_ME, NEW_DXCC, NEW_PREFIX } + + /** Whether any per-decode announcement toggle is on (cheap early-out). */ + public static boolean anyDecodeAnnounceEnabled( + boolean announceCalling, boolean announceNewDxcc, boolean announceNewPrefix) { + return announceCalling || announceNewDxcc || announceNewPrefix; + } + + /** + * Decide which announcement (if any) a decoded message earns. + * + * @param announceCalling the voiceAnnounceCalling user toggle + * @param announceNewDxcc the voiceAnnounceNewDxcc user toggle + * @param announceNewPrefix the voiceAnnounceNewPrefix user toggle + * @param addressedToMe message's target callsign is mine + * @param isCq message is a CQ broadcast + * @param fromNewDxcc sender is a new (unworked) DXCC entity + * @param fromNewPrefix sender carries a new (unworked) WPX prefix + * @param blocked message is filtered by the user's block list + * @return the announcement to speak, or null for silence + */ + public static Kind decide(boolean announceCalling, boolean announceNewDxcc, + boolean announceNewPrefix, boolean addressedToMe, + boolean isCq, boolean fromNewDxcc, boolean fromNewPrefix, + boolean blocked) { + if (blocked) return null; + if (announceCalling && addressedToMe) return Kind.CALLING_ME; + if (!isCq) return null; + if (announceNewDxcc && fromNewDxcc) return Kind.NEW_DXCC; + if (announceNewPrefix && fromNewPrefix) return Kind.NEW_PREFIX; + return null; + } + + /** + * Claim the right to speak {@code dedupKey}, returning true only when the + * caller should proceed. The speakability gate ({@code canSpeak} — false + * while transmitting, when TTS would leak into the rig audio) is evaluated + * BEFORE the dedup set is touched: burning the key while muted would + * silence that station for the whole session, so a station first heard + * during a transmission still gets announced on its next decode. + */ + public static boolean claim(Set spoken, String dedupKey, boolean canSpeak) { + if (!canSpeak) return false; // gate FIRST — do not burn the key while muted + return spoken.add(dedupKey); + } + + /** One announcement per calling station per session. */ + public static String callingMeKey(String fromCallsign) { + return "VCALL:" + norm(fromCallsign); + } + + /** One announcement per new country per session. */ + public static String newDxccKey(String country) { + return "VDXCC:" + norm(country); + } + + /** One announcement per new prefix per session. */ + public static String newPrefixKey(String prefix) { + return "VPREFIX:" + norm(prefix); + } + + /** One announcement per logged contact (station + completion time). */ + public static String qsoCompleteKey(String toCallsign, String endTime) { + return "VQSO:" + norm(toCallsign) + "|" + norm(endTime); + } + + private static String norm(String s) { + // Locale.ROOT: default-locale casing (e.g. Turkish dotted/dotless I) + // would make dedup keys differ between devices for the same station. + return s == null ? "" : s.trim().toUpperCase(Locale.ROOT); + } +} diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncer.java b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncer.java new file mode 100644 index 000000000..ba1a2786b --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnnouncer.java @@ -0,0 +1,169 @@ +package com.k1af.ft8af.voice; + +import android.content.Context; +import android.speech.tts.TextToSpeech; + +import com.k1af.ft8af.GeneralVariables; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Thin Android TextToSpeech wrapper for the voice assistant. Owned by + * {@code DxAlertNotifier} (the same object that already sees every decode + * batch and QSO completion). + * + *

The one hard rule (see the TX-audio hazards in the project docs): TTS + * must NEVER play while the rig is transmitting — the utterance would be + * mixed into the TX audio and go out over the air. Two layers enforce it: + *

    + *
  • {@link #speak} refuses to start an utterance while the + * {@link TransmitGate} reports TX active, and
  • + *
  • MainViewModel observes {@code mutableIsTransmitting} and calls + * {@link #stopNow()} the instant it flips true, cutting off anything + * already speaking.
  • + *
+ * + *

TextToSpeech init is lazy (first {@link #speak}) so no TTS engine is + * spun up for users who never enable a voice toggle. Utterances queued while + * the engine is still initializing are buffered (bounded) and flushed from + * onInit — re-checking the transmit gate at flush time. + */ +public class VoiceAnnouncer { + + /** Runtime TX check, wired to {@code FT8TransmitSignal.isTransmitting()}. */ + public interface TransmitGate { + boolean isTransmitting(); + } + + private static final int MAX_PENDING = 5; + + private final Context appContext; + private final Object initLock = new Object(); + private volatile TextToSpeech tts; + private volatile boolean ttsReady = false; + private volatile boolean ttsFailed = false; + // Utterances requested before onInit landed. Guarded by initLock. + private final List pending = new ArrayList<>(); + // Per-session spoken dedup keys (see VoiceAnnouncementDecisions.claim). + private final Set spoken = Collections.newSetFromMap(new ConcurrentHashMap<>()); + private volatile TransmitGate transmitGate = null; + + public VoiceAnnouncer(Context context) { + this.appContext = context != null ? context.getApplicationContext() : null; + } + + public void setTransmitGate(TransmitGate gate) { + this.transmitGate = gate; + } + + /** The per-session dedup key set, for {@code VoiceAnnouncementDecisions.claim}. */ + public Set spokenKeys() { + return spoken; + } + + /** False while the rig is transmitting — speaking then would go out over the air. */ + public boolean canSpeakNow() { + TransmitGate gate = transmitGate; + return gate == null || !gate.isTransmitting(); + } + + /** + * Queue an utterance (QUEUE_ADD — announcements never cut each other off). + * Silently dropped while transmitting; the caller's dedup claim should be + * gated on {@link #canSpeakNow()} so the key isn't burned in that case. + */ + public void speak(String text, String utteranceId) { + if (appContext == null || text == null || text.isEmpty()) return; + if (!canSpeakNow()) return; + + synchronized (initLock) { + if (ttsFailed) return; + if (!ttsReady) { + if (tts == null) { + initTts(); + } + if (pending.size() < MAX_PENDING) { + pending.add(new String[]{text, utteranceId}); + } + return; + } + } + speakNow(text, utteranceId); + } + + /** Cut off the current utterance and drop anything queued. TX just started. */ + public void stopNow() { + synchronized (initLock) { + pending.clear(); + } + TextToSpeech engine = tts; + if (engine != null && ttsReady) { + try { + engine.stop(); + } catch (Exception ignored) { + // Engine died — nothing is speaking, which is all stop wanted. + } + } + } + + public void shutdown() { + TextToSpeech engine; + synchronized (initLock) { + pending.clear(); + engine = tts; + tts = null; + ttsReady = false; + } + if (engine != null) { + try { + engine.shutdown(); + } catch (Exception ignored) { + } + } + } + + // Must be called with initLock held. + private void initTts() { + tts = new TextToSpeech(appContext, status -> { + List toFlush = null; + synchronized (initLock) { + if (status == TextToSpeech.SUCCESS && tts != null) { + try { + tts.setLanguage(Locale.US); + } catch (Exception ignored) { + // Engine keeps its default language; still usable. + } + ttsReady = true; + toFlush = new ArrayList<>(pending); + } else { + ttsFailed = true; + GeneralVariables.fileLog("VoiceAnnouncer: TTS init failed status=" + status); + } + pending.clear(); + } + if (toFlush != null) { + for (String[] entry : toFlush) { + // Re-check the gate: TX may have started while init ran. + if (!canSpeakNow()) break; + speakNow(entry[0], entry[1]); + } + } + }); + } + + private void speakNow(String text, String utteranceId) { + TextToSpeech engine = tts; + if (engine == null) return; + try { + engine.speak(text, TextToSpeech.QUEUE_ADD, null, + utteranceId == null ? String.valueOf(text.hashCode()) : utteranceId); + } catch (Exception e) { + GeneralVariables.fileLog("VoiceAnnouncer: speak failed: " + e.getMessage()); + } + } +} diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnswerSelector.java b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnswerSelector.java new file mode 100644 index 000000000..3eb3f7d17 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceAnswerSelector.java @@ -0,0 +1,74 @@ +package com.k1af.ft8af.voice; + +import java.util.List; + +/** + * Pure, Android-free candidate selection for the "answer" voice command: + * which decoded message should {@code MainViewModel.callStation} be handed? + * + *

Preference order: + *

    + *
  1. The newest decode addressed to my callsign (a station actively + * calling me right now).
  2. + *
  3. Else the newest decode from the head of the caller queue (a station + * that called me earlier and is waiting its turn).
  4. + *
  5. Else null — nothing to answer.
  6. + *
+ * + *

Generic over the message type (with tiny accessor interfaces instead of + * {@code java.util.function} — minSdk 23, no core-library desugaring) so the + * selection logic is testable without Android's {@code Ft8Message}. + */ +public final class VoiceAnswerSelector { + private VoiceAnswerSelector() {} + + /** Whether a message is addressed to my callsign. */ + public interface AddressedToMe { + boolean test(T message); + } + + /** The sender callsign of a message (may be null/empty for junk rows). */ + public interface SenderOf { + String get(T message); + } + + /** + * @param decodesOldestFirst the decode list in arrival order (the app's + * ft8Messages list appends newest last) + * @param addressedToMe resolved by the caller via checkIsMyCallsign + * @param senderOf sender-callsign accessor + * @param queueHeadCallsign callsign at the head of the caller queue, or + * null when the queue is empty + * @return the message to answer, or null when there is no candidate + */ + public static T pick(List decodesOldestFirst, + AddressedToMe addressedToMe, + SenderOf senderOf, + String queueHeadCallsign) { + if (decodesOldestFirst == null || decodesOldestFirst.isEmpty()) return null; + + // Newest-first scan for a station calling me. + for (int i = decodesOldestFirst.size() - 1; i >= 0; i--) { + T msg = decodesOldestFirst.get(i); + if (msg == null) continue; + if (!hasSender(senderOf.get(msg))) continue; + if (addressedToMe.test(msg)) return msg; + } + + // Fall back to the queued caller's newest decode. + if (queueHeadCallsign != null && !queueHeadCallsign.trim().isEmpty()) { + for (int i = decodesOldestFirst.size() - 1; i >= 0; i--) { + T msg = decodesOldestFirst.get(i); + if (msg == null) continue; + String sender = senderOf.get(msg); + if (!hasSender(sender)) continue; + if (sender.trim().equalsIgnoreCase(queueHeadCallsign.trim())) return msg; + } + } + return null; + } + + private static boolean hasSender(String sender) { + return sender != null && !sender.trim().isEmpty(); + } +} diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceCommandParser.java b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceCommandParser.java new file mode 100644 index 000000000..19590d7d2 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoiceCommandParser.java @@ -0,0 +1,80 @@ +package com.k1af.ft8af.voice; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +/** + * Pure, Android-free keyword parser for one-shot voice commands (no NLU, no + * network). The recognizer's top transcription is normalized (lowercased, + * punctuation stripped, whitespace collapsed) and matched against a small + * fixed keyword set; anything else is {@link Command#UNKNOWN}. + * + *

Match priority when an utterance contains several keywords: STOP beats + * everything ("stop calling CQ" must stop, not CQ), then ANSWER, SKIP, LOG, + * and CALL_CQ last. Recognizers often mangle "CQ" into "seek you" or "c q", + * so those variants are accepted for CALL_CQ. + */ +public final class VoiceCommandParser { + private VoiceCommandParser() {} + + public enum Command { ANSWER, CALL_CQ, STOP, SKIP, LOG, UNKNOWN } + + private static final Set STOP_WORDS = + new HashSet<>(Arrays.asList("stop", "halt", "cancel")); + private static final Set ANSWER_WORDS = + new HashSet<>(Arrays.asList("answer", "reply")); + private static final Set SKIP_WORDS = + new HashSet<>(Arrays.asList("skip", "next")); + private static final Set LOG_WORDS = + new HashSet<>(Arrays.asList("log", "logged")); + + /** Parse a recognizer transcription into a {@link Command}. Null-safe. */ + public static Command parse(String utterance) { + if (utterance == null) return Command.UNKNOWN; + String norm = normalize(utterance); + if (norm.isEmpty()) return Command.UNKNOWN; + + Set tokens = new HashSet<>(Arrays.asList(norm.split(" "))); + + if (containsAny(tokens, STOP_WORDS)) return Command.STOP; + if (containsAny(tokens, ANSWER_WORDS)) return Command.ANSWER; + if (containsAny(tokens, SKIP_WORDS)) return Command.SKIP; + if (containsAny(tokens, LOG_WORDS)) return Command.LOG; + if (isCallCq(norm, tokens)) return Command.CALL_CQ; + return Command.UNKNOWN; + } + + /** Lowercase, strip everything but letters/digits, collapse whitespace. */ + static String normalize(String utterance) { + String lower = utterance.toLowerCase(Locale.ROOT); + StringBuilder sb = new StringBuilder(lower.length()); + for (int i = 0; i < lower.length(); i++) { + char c = lower.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { + sb.append(c); + } else { + sb.append(' '); + } + } + return sb.toString().trim().replaceAll("\\s+", " "); + } + + private static boolean containsAny(Set tokens, Set words) { + for (String w : words) { + if (tokens.contains(w)) return true; + } + return false; + } + + /** + * "CQ" arrives from recognizers as the token "cq", the mondegreen + * "seek you", or two spelled letters "c q" — accept all three. + */ + private static boolean isCallCq(String norm, Set tokens) { + if (tokens.contains("cq")) return true; + if (norm.contains("seek you")) return true; + return norm.contains("c q"); + } +} diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoicePhrases.java b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoicePhrases.java new file mode 100644 index 000000000..e54b005e6 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/voice/VoicePhrases.java @@ -0,0 +1,98 @@ +package com.k1af.ft8af.voice; + +/** + * Pure, Android-free builders for the exact spoken strings the voice + * assistant utters. English-only for v1 (speech only — UI strings still live + * in resources). All phrases are deliberately short (well under ~3 s of + * speech) so a stop-on-TX never has much to cut off. + * + *

Callsigns are spelled letter-by-letter ("K1ABC" → "K 1 A B C") so the + * TTS engine reads them as call signs instead of trying to pronounce them as + * words. + */ +public final class VoicePhrases { + private VoicePhrases() {} + + /** Mirrors {@code Ft8Message.SNR_UNKNOWN} without dragging that class in. */ + public static final int SNR_UNKNOWN = Integer.MIN_VALUE; + + /** + * Spell a callsign for TTS: one space between characters, '/' spoken as + * "stroke" (ham convention for portable/compound calls). + */ + public static String spellCallsign(String callsign) { + if (callsign == null) return ""; + String trimmed = callsign.trim(); + StringBuilder sb = new StringBuilder(trimmed.length() * 2); + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + if (sb.length() > 0) sb.append(' '); + if (c == '/') { + sb.append("stroke"); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + /** + * SNR as speech: "minus 5", "plus 3", "zero". Empty string for the + * {@link #SNR_UNKNOWN} sentinel (the decoder can emit a valid message + * with no SNR — mirror DxAlertNotifier's body formatters and drop it). + */ + public static String snrPhrase(int snr) { + if (snr == SNR_UNKNOWN) return ""; + if (snr < 0) return "minus " + (-snr); + if (snr == 0) return "zero"; + return "plus " + snr; + } + + /** "K 1 A B C calling you, minus 5" (SNR clause dropped when unknown). */ + public static String callingYou(String callsign, int snr) { + String base = spellCallsign(callsign) + " calling you"; + String snrPart = snrPhrase(snr); + return snrPart.isEmpty() ? base : base + ", " + snrPart; + } + + /** "QSO with K 1 A B C logged" */ + public static String qsoLogged(String callsign) { + return "QSO with " + spellCallsign(callsign) + " logged"; + } + + /** + * "New country: Japan". The caller passes the resolved country name, or a + * pre-spelled callsign (via {@link #spellCallsign}) when no name resolved. + */ + public static String newCountry(String spokenCountry) { + return "New country: " + spokenCountry; + } + + /** "New prefix: W 1" — prefixes are call fragments, so always spelled. */ + public static String newPrefix(String prefix) { + return "New prefix: " + spellCallsign(prefix); + } + + // ---- Command echo confirmations (spoken after a voice command runs) ---- + + public static String echoCallingCq() { + return "Calling CQ"; + } + + public static String echoStopping() { + return "Stopping"; + } + + public static String echoSkipping() { + return "Back to CQ"; + } + + public static String echoLogged() { + return "Logged"; + } + + /** "Answering K 1 A B C" */ + public static String echoAnswering(String callsign) { + return "Answering " + spellCallsign(callsign); + } +} diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamRecorder.java index 82de9a795..e8829dd61 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamRecorder.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/HamRecorder.java @@ -127,6 +127,30 @@ public boolean isRunning() { return isRunning; } + /** + * Pure decision: is FT8 RX holding an Android audio-capture session (an + * AudioRecord) right now? True blocks the voice-command SpeechRecognizer — + * two capture clients fight under Android's concurrency rules and the + * loser (usually our decode chain) goes silent. + * + * @param running the recorder is running at all + * @param micSource audio comes from MicRecorder (not a LAN connector) + * @param usbDirect MicRecorder captures via direct libusb (no AudioRecord) + */ + static boolean phoneMicCaptureInUse(boolean running, boolean micSource, boolean usbDirect) { + return running && micSource && !usbDirect; + } + + /** + * Runtime "phone mic in use by FT8 RX" signal for the voice-command UI: + * true whenever this recorder holds an AudioRecord session (system mic or + * Android-routed USB input); false for direct-libusb USB capture and for + * LAN audio sources (ICOM WiFi / Flex), where the capture stack is free. + */ + public boolean isPhoneMicInUse() { + return phoneMicCaptureInUse(isRunning, isMicRecord, micRecorder.isUsingUsbDirect()); + } + /** * Start recording. This method keeps the device in a continuous recording state. * Recording data is retrieved through the listener class GetVoiceData. diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java index 3cf65cf94..39a2fc598 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java @@ -190,6 +190,17 @@ public MicRecorder(){ } } + /** + * True when capture runs over the direct-libusb USB path — no AudioRecord + * exists, so Android's audio capture stack (and the phone mic) is free for + * other clients (e.g. the voice-command SpeechRecognizer). False whenever + * an AudioRecord is in play, including the "preferred device" USB-UAC + * route (that still holds an Android capture session). + */ + public boolean isUsingUsbDirect() { + return useUsbAudio; + } + /** * Open and configure a USB audio device for input. */ diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt index d138b946b..89a3466a1 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt @@ -59,6 +59,7 @@ import radio.ks3ckc.ft8af.ui.components.SlotTimerBar import radio.ks3ckc.ft8af.ui.components.TabBar import radio.ks3ckc.ft8af.ui.components.TransmitGlow import radio.ks3ckc.ft8af.ui.components.TxStrip +import radio.ks3ckc.ft8af.ui.components.VoiceCommandButton import radio.ks3ckc.ft8af.ui.components.selectBandIndex import radio.ks3ckc.ft8af.ui.decode.DecodeScreen import radio.ks3ckc.ft8af.ui.logbook.LogbookScreen @@ -400,6 +401,18 @@ fun FT8AFApp(mainViewModel: MainViewModel) { .padding(bottom = WaterfallBottomStripHeight), ) } + + // Voice-command push-to-talk mic (v1). Floats over the content + // just above the TX strip so it never resizes the waterfall's + // AndroidView or shifts list layouts. Renders nothing unless + // the voice-commands setting is on; tap is refused (with the + // reason) while FT8 RX is capturing through the phone mic. + VoiceCommandButton( + mainViewModel = mainViewModel, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 12.dp, bottom = 12.dp), + ) } // Active QSO panel (docked) — slides up above TxStrip when a QSO is diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/FT8AFIcons.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/FT8AFIcons.kt index 0a8671837..6c34085ac 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/FT8AFIcons.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/FT8AFIcons.kt @@ -474,6 +474,42 @@ object FT8AFIcons { drawPath(crown, tint, style = stroke) } } + + /** Microphone — voice-command push-to-talk button. */ + @Composable + fun Mic( + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + size: Dp = 22.dp, + strokeWidth: Float = 1.6f, + ) { + val tint = if (color == Color.Unspecified) androidx.compose.material3.MaterialTheme.colorScheme.onSurface else color + Canvas(modifier = modifier.then(Modifier.sizeOf(size))) { + val s = this.size.width / 24f + val stroke = strokeStyle(strokeWidth * s) + // Capsule body + drawRoundRect( + color = tint, + topLeft = Offset(9f * s, 3f * s), + size = Size(6f * s, 10f * s), + cornerRadius = CornerRadius(3f * s, 3f * s), + style = stroke, + ) + // Cradle arc (open at the top) + drawArc( + color = tint, + startAngle = 0f, + sweepAngle = 180f, + useCenter = false, + topLeft = Offset(6f * s, 5f * s), + size = Size(12f * s, 12f * s), + style = stroke, + ) + // Stand + base + drawLine(tint, Offset(12f * s, 17f * s), Offset(12f * s, 20f * s), stroke.width, StrokeCap.Round) + drawLine(tint, Offset(9f * s, 20f * s), Offset(15f * s, 20f * s), stroke.width, StrokeCap.Round) + } + } } // Extension to apply a dp size uniformly diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButton.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButton.kt new file mode 100644 index 000000000..ca9bc605d --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButton.kt @@ -0,0 +1,312 @@ +package radio.ks3ckc.ft8af.ui.components + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.k1af.ft8af.GeneralVariables +import com.k1af.ft8af.MainViewModel +import com.k1af.ft8af.R +import com.k1af.ft8af.voice.VoiceCommandParser +import com.k1af.ft8af.voice.VoicePhrases +import radio.ks3ckc.ft8af.theme.Accent +import radio.ks3ckc.ft8af.theme.BgSurface2 +import radio.ks3ckc.ft8af.theme.Border +import radio.ks3ckc.ft8af.theme.TextFaint +import radio.ks3ckc.ft8af.theme.TextPrimary + +/** + * Visual/interaction state of the voice-command push-to-talk button. + * Extracted as a pure function so the gating is unit-testable (the + * Composable below is a thin wrapper). + */ +internal enum class VoiceButtonState { HIDDEN, READY, BLOCKED_MIC } + +/** + * @param commandsEnabled the voiceCommandsEnabled user setting + * @param phoneMicInUse FT8 RX holds an Android audio-capture session + * (MainViewModel.isPhoneMicInUse) — a SpeechRecognizer + * would fight it, so the button is shown disabled with + * an explanatory toast instead of silently failing + */ +internal fun voiceButtonState( + commandsEnabled: Boolean, + phoneMicInUse: Boolean, +): VoiceButtonState = + when { + !commandsEnabled -> VoiceButtonState.HIDDEN + phoneMicInUse -> VoiceButtonState.BLOCKED_MIC + else -> VoiceButtonState.READY + } + +/** + * Toast string resource for a recognizer error code, or null for codes that + * must stay silent: ERROR_CLIENT is what the recognizer emits after a + * user-initiated cancel() (second tap), and toasting it would make a + * deliberate cancel look like a failure. Pure so the mapping is testable. + */ +internal fun voiceErrorToastRes(errorCode: Int): Int? = + when (errorCode) { + SpeechRecognizer.ERROR_CLIENT -> null + SpeechRecognizer.ERROR_NO_MATCH, + SpeechRecognizer.ERROR_SPEECH_TIMEOUT, + -> R.string.voice_not_understood + else -> R.string.voice_recognizer_error + } + +/** + * The spoken confirmation for an executed voice command, or null when there + * is nothing to echo (UNKNOWN, or ANSWER with no candidate — those get toasts + * instead). Pure; the phrases themselves live in [VoicePhrases]. + */ +internal fun echoPhraseFor( + command: VoiceCommandParser.Command, + answeredCallsign: String?, +): String? = + when (command) { + VoiceCommandParser.Command.CALL_CQ -> VoicePhrases.echoCallingCq() + VoiceCommandParser.Command.STOP -> VoicePhrases.echoStopping() + VoiceCommandParser.Command.SKIP -> VoicePhrases.echoSkipping() + VoiceCommandParser.Command.LOG -> VoicePhrases.echoLogged() + VoiceCommandParser.Command.ANSWER -> + answeredCallsign?.let { VoicePhrases.echoAnswering(it) } + VoiceCommandParser.Command.UNKNOWN -> null + } + +/** + * Push-to-talk mic button for one-shot voice commands ("answer", "call CQ", + * "stop", "skip", "log it"). NOT always-listening: one tap = one recognition + * pass, on-device preferred. Visible only when the voice-commands setting is + * on; functionally disabled (tap explains why) while FT8 RX is capturing via + * the phone-mic AudioRecord path — v1 never pauses the capture chain. + */ +@Composable +fun VoiceCommandButton( + mainViewModel: MainViewModel, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + // Recorder run-state, READ below so this composable subscribes and + // re-evaluates the mic gate when recording starts/stops. Source changes + // that don't flip the run-state (mic <-> LAN) are picked up on the next + // ambient recomposition (slot timer / TX state churn in FT8AFApp). + val recorderRunning by mainViewModel.mutableHamRecordIsRunning.observeAsState(false) + + // Observable mirror of the setting — a plain static read here would only be + // re-evaluated on unrelated recompositions, so the button wouldn't appear or + // disappear until app restart when the toggle flips. + val commandsEnabled by GeneralVariables.mutableVoiceCommandsEnabled + .observeAsState(GeneralVariables.voiceCommandsEnabled) + + val state = voiceButtonState( + commandsEnabled = commandsEnabled == true, + // isPhoneMicInUse() already includes the running check; the LiveData + // read is ANDed in to make this a tracked snapshot dependency. + phoneMicInUse = recorderRunning == true && mainViewModel.isPhoneMicInUse(), + ) + if (state == VoiceButtonState.HIDDEN) return + + var listening by remember { mutableStateOf(false) } + // One recognizer per composition, created lazily on first tap. + val recognizerHolder = remember { arrayOfNulls(1) } + DisposableEffect(Unit) { + onDispose { + recognizerHolder[0]?.destroy() + recognizerHolder[0] = null + } + } + + val readyDesc = stringResource(R.string.voice_button_desc) + val busyDesc = stringResource(R.string.voice_button_mic_busy_desc) + val desc = if (state == VoiceButtonState.BLOCKED_MIC) busyDesc else readyDesc + + val tint = when { + listening -> Accent + state == VoiceButtonState.BLOCKED_MIC -> TextFaint + else -> TextPrimary + } + + IconButton( + onClick = { + when { + state == VoiceButtonState.BLOCKED_MIC -> + Toast + .makeText(context, context.getString(R.string.voice_mic_busy), Toast.LENGTH_LONG) + .show() + listening -> { + // Second tap cancels the in-flight listen. + recognizerHolder[0]?.cancel() + listening = false + } + else -> listening = startVoiceRecognition( + context = context, + recognizerHolder = recognizerHolder, + onDone = { transcription -> + listening = false + handleVoiceResult(mainViewModel, context, transcription) + }, + onError = { errorCode -> + listening = false + val msgRes = voiceErrorToastRes(errorCode) + if (msgRes != null) { + Toast + .makeText(context, context.getString(msgRes), Toast.LENGTH_SHORT) + .show() + } + }, + ) + } + }, + modifier = modifier + .size(44.dp) + .clip(CircleShape) + .background(BgSurface2.copy(alpha = 0.92f)) + .border(1.dp, if (listening) Accent else Border, CircleShape) + .semantics { contentDescription = desc }, + ) { + FT8AFIcons.Mic(color = tint, size = 22.dp) + } +} + +/** + * Kick off a single on-device recognition pass. Returns true when listening + * actually started (recognition available and recognizer created). + */ +private fun startVoiceRecognition( + context: Context, + recognizerHolder: Array, + onDone: (String?) -> Unit, + onError: (Int) -> Unit, +): Boolean { + if (!SpeechRecognizer.isRecognitionAvailable(context)) { + Toast + .makeText(context, context.getString(R.string.voice_recognizer_unavailable), Toast.LENGTH_LONG) + .show() + return false + } + val recognizer = recognizerHolder[0] ?: SpeechRecognizer.createSpeechRecognizer(context) + .also { recognizerHolder[0] = it } + + recognizer.setRecognitionListener(object : RecognitionListener { + override fun onResults(results: Bundle?) { + val top = results + ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + ?.firstOrNull() + onDone(top) + } + + override fun onError(error: Int) = onError(error) + + override fun onReadyForSpeech(params: Bundle?) {} + + override fun onBeginningOfSpeech() {} + + override fun onRmsChanged(rmsdB: Float) {} + + override fun onBufferReceived(buffer: ByteArray?) {} + + override fun onEndOfSpeech() {} + + override fun onPartialResults(partialResults: Bundle?) {} + + override fun onEvent( + eventType: Int, + params: Bundle?, + ) {} + }) + + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra( + RecognizerIntent.EXTRA_LANGUAGE_MODEL, + RecognizerIntent.LANGUAGE_MODEL_FREE_FORM, + ) + putExtra(RecognizerIntent.EXTRA_LANGUAGE, "en-US") + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1) + // Small fixed grammar — the on-device recognizer is plenty, and no + // audio leaves the phone. + putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, true) + } + recognizer.startListening(intent) + Toast + .makeText(context, context.getString(R.string.voice_listening), Toast.LENGTH_SHORT) + .show() + return true +} + +/** Parse the transcription, run the mapped action, and give feedback. */ +private fun handleVoiceResult( + mainViewModel: MainViewModel, + context: Context, + transcription: String?, +) { + val command = VoiceCommandParser.parse(transcription) + GeneralVariables.fileLog("VOICE command [" + (transcription ?: "") + "] -> " + command) + + var answeredCallsign: String? = null + when (command) { + VoiceCommandParser.Command.ANSWER -> { + answeredCallsign = mainViewModel.voiceAnswerBestCaller() + if (answeredCallsign == null) { + Toast + .makeText(context, context.getString(R.string.voice_no_caller), Toast.LENGTH_SHORT) + .show() + return + } + } + VoiceCommandParser.Command.CALL_CQ -> { + if (GeneralVariables.myCallsign.isNullOrEmpty()) { + Toast + .makeText(context, context.getString(R.string.app_set_callsign_first), Toast.LENGTH_SHORT) + .show() + return + } + // The canonical CQ-start sequence (same as the TX strip's CQ button). + mainViewModel.ft8TransmitSignal.setTransmitFreeText(false) + mainViewModel.ft8TransmitSignal.userResetToCQ() + mainViewModel.ft8TransmitSignal.setActivated(true) + GeneralVariables.resetLaunchSupervision() + } + VoiceCommandParser.Command.STOP -> + mainViewModel.ft8TransmitSignal.setActivated(false) + VoiceCommandParser.Command.SKIP -> + mainViewModel.ft8TransmitSignal.userResetToCQ() + VoiceCommandParser.Command.LOG -> + mainViewModel.ft8TransmitSignal.forceLogAndMoveOn() + VoiceCommandParser.Command.UNKNOWN -> { + Toast + .makeText(context, context.getString(R.string.voice_not_understood), Toast.LENGTH_SHORT) + .show() + return + } + } + + // Spoken confirmation. Unique utterance id — echoes are never deduped — + // and the announcer's transmit gate silently drops it mid-TX. + val echo = echoPhraseFor(command, answeredCallsign) + if (echo != null) { + mainViewModel.voiceAnnouncer.speak(echo, "VECHO:" + System.nanoTime()) + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt index 4a78839e1..1ba5f2bea 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt @@ -69,6 +69,7 @@ private enum class SettingsCategory { TRANSMISSION, TIME_SYNC, DECODE_FILTERS, + VOICE, LOGGING, ROAD_TRIP, ADVANCED, @@ -142,6 +143,8 @@ fun SettingsScreen( TimeSyncSettings(mainViewModel, onBack = { currentCategory = null }) SettingsCategory.DECODE_FILTERS -> DecodeFilterSettings(mainViewModel, onBack = { currentCategory = null }) + SettingsCategory.VOICE -> + VoiceSettings(mainViewModel, onBack = { currentCategory = null }) SettingsCategory.LOGGING -> LoggingSettings(mainViewModel, onBack = { currentCategory = null }) SettingsCategory.ROAD_TRIP -> @@ -321,6 +324,13 @@ private fun SettingsLanding( onClick = { onOpenCategory(SettingsCategory.DECODE_FILTERS) }, ) SectionDivider() + SettingsRow( + label = stringResource(R.string.settings_cat_voice), + description = stringResource(R.string.settings_cat_voice_desc), + showChevron = true, + onClick = { onOpenCategory(SettingsCategory.VOICE) }, + ) + SectionDivider() SettingsRow( label = stringResource(R.string.settings_cat_logging), showChevron = true, diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/VoiceSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/VoiceSettings.kt new file mode 100644 index 000000000..1d77e9531 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/VoiceSettings.kt @@ -0,0 +1,136 @@ +package radio.ks3ckc.ft8af.ui.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.k1af.ft8af.GeneralVariables +import com.k1af.ft8af.MainViewModel +import com.k1af.ft8af.R +import radio.ks3ckc.ft8af.theme.TextFaint +import radio.ks3ckc.ft8af.ui.components.GlassCard +import radio.ks3ckc.ft8af.ui.components.SettingsRow + +/** + * Voice-assistant settings: per-event spoken announcement toggles (TTS) and + * the push-to-talk voice-command button toggle (STT). All persisted through + * the SQLite config table (writeConfig) and hydrated in DatabaseOpr — same + * pattern as the needed-DX alert toggles. + */ +@Composable +fun VoiceSettings( + mainViewModel: MainViewModel, + onBack: () -> Unit, +) { + var announceCalling by remember { mutableStateOf(GeneralVariables.voiceAnnounceCalling) } + var announceQso by remember { mutableStateOf(GeneralVariables.voiceAnnounceQsoComplete) } + var announceNewDxcc by remember { mutableStateOf(GeneralVariables.voiceAnnounceNewDxcc) } + var announceNewPrefix by remember { mutableStateOf(GeneralVariables.voiceAnnounceNewPrefix) } + var commandsEnabled by remember { mutableStateOf(GeneralVariables.voiceCommandsEnabled) } + + SettingsDetailScaffold( + title = stringResource(R.string.settings_cat_voice), + onBack = onBack, + ) { + // ===================================================================== + // SPOKEN ANNOUNCEMENTS (TTS) + // ===================================================================== + SettingsSection(title = stringResource(R.string.settings_section_voice_announce)) { + GlassCard(modifier = Modifier.fillMaxWidth()) { + Column { + SettingsRow( + label = stringResource(R.string.settings_voice_announce_calling), + description = stringResource(R.string.settings_voice_announce_calling_desc), + toggle = announceCalling, + onToggleChange = { checked -> + announceCalling = checked + GeneralVariables.voiceAnnounceCalling = checked + mainViewModel.databaseOpr.writeConfig( + "voiceAnnounceCalling", if (checked) "1" else "0", null, + ) + }, + ) + SectionDivider() + SettingsRow( + label = stringResource(R.string.settings_voice_announce_qso), + description = stringResource(R.string.settings_voice_announce_qso_desc), + toggle = announceQso, + onToggleChange = { checked -> + announceQso = checked + GeneralVariables.voiceAnnounceQsoComplete = checked + mainViewModel.databaseOpr.writeConfig( + "voiceAnnounceQsoComplete", if (checked) "1" else "0", null, + ) + }, + ) + SectionDivider() + SettingsRow( + label = stringResource(R.string.settings_voice_announce_new_dxcc), + description = stringResource(R.string.settings_voice_announce_new_dxcc_desc), + toggle = announceNewDxcc, + onToggleChange = { checked -> + announceNewDxcc = checked + GeneralVariables.voiceAnnounceNewDxcc = checked + mainViewModel.databaseOpr.writeConfig( + "voiceAnnounceNewDxcc", if (checked) "1" else "0", null, + ) + }, + ) + SectionDivider() + SettingsRow( + label = stringResource(R.string.settings_voice_announce_new_prefix), + description = stringResource(R.string.settings_voice_announce_new_prefix_desc), + toggle = announceNewPrefix, + onToggleChange = { checked -> + announceNewPrefix = checked + GeneralVariables.voiceAnnounceNewPrefix = checked + mainViewModel.databaseOpr.writeConfig( + "voiceAnnounceNewPrefix", if (checked) "1" else "0", null, + ) + }, + ) + } + } + Text( + text = stringResource(R.string.settings_voice_tx_note), + color = TextFaint, + fontSize = 11.sp, + lineHeight = 15.sp, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } + + // ===================================================================== + // VOICE COMMANDS (STT) + // ===================================================================== + SettingsSection(title = stringResource(R.string.settings_section_voice_commands)) { + GlassCard(modifier = Modifier.fillMaxWidth()) { + SettingsRow( + label = stringResource(R.string.settings_voice_commands), + description = stringResource(R.string.settings_voice_commands_desc), + toggle = commandsEnabled, + onToggleChange = { checked -> + commandsEnabled = checked + GeneralVariables.voiceCommandsEnabled = checked + // The mic button on the (already-composed) main screen + // observes this mirror; without it the change only shows + // after an app restart. + GeneralVariables.mutableVoiceCommandsEnabled.value = checked + mainViewModel.databaseOpr.writeConfig( + "voiceCommandsEnabled", if (checked) "1" else "0", null, + ) + }, + ) + } + } + } +} diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 22827d305..4da4fc7cb 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -721,6 +721,31 @@ Callsign watchlist Comma-separated calls or prefixes (e.g. 3Y0, W1AW, TX7). An alert fires the instant a match is decoded — matched by prefix, so a DXpedition prefix catches every variant. Watchlist: %1$s + + + Voice Assistant + Spoken announcements and hands-free voice commands + Spoken announcements + Voice commands + Station calling me + Speak the callsign and signal report when a station calls you + QSO completed + Speak a confirmation when a QSO is logged + New DXCC CQ + Speak when an unworked DXCC entity calls CQ + New prefix CQ + Speak when an unworked WPX prefix calls CQ + Voice command button + Show a push-to-talk mic button on the main screen. Say \"answer\", \"call CQ\", \"stop\", \"skip\", or \"log it\". Unavailable while FT8 receive is capturing through Android audio (phone mic or Android-routed USB). + Announcements never play while transmitting, so they can\'t leak into the rig audio. + Voice command + Voice command unavailable: audio input in use by FT8 receive + Voice commands unavailable: FT8 receive is capturing through Android audio (phone mic or Android-routed USB). Direct-USB or network rig audio frees it. + Listening… + Didn\'t catch a command. Try \"answer\", \"call CQ\", \"stop\", \"skip\", or \"log it\". + No station to answer + Speech recognition is not available on this device + Speech recognition error Background receiving Keeps FT8 decoding running while the app is in the background or the screen is off FT8AF is receiving diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java index a2d5db073..b5c25ca81 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/database/DatabaseOprConfigHydrationTest.java @@ -287,6 +287,59 @@ public void keepScreenOn_absentFromConfigKeepsThePreviousBehaviour() { assertThat(GeneralVariables.keepScreenOn).isTrue(); } + // ---- voice-command toggle + its observable mirror ------------------------- + // The main-screen mic button observes mutableVoiceCommandsEnabled (a plain + // static read there is never recomposed), so hydration must update BOTH the + // static and the LiveData mirror or a persisted "on" renders no button until + // the setting is touched. + + @Test + public void voiceCommandsEnabled_hydrationUpdatesStaticAndLiveDataMirror() { + boolean origEnabled = GeneralVariables.voiceCommandsEnabled; + Boolean origMirror = GeneralVariables.mutableVoiceCommandsEnabled.getValue(); + try { + GeneralVariables.voiceCommandsEnabled = false; + GeneralVariables.mutableVoiceCommandsEnabled.setValue(false); + + Map config = new LinkedHashMap<>(); + config.put("voiceCommandsEnabled", "1"); + opr.writeConfigSync(config); + + hydrate(); + // postValue lands via the main looper; run it so getValue() sees it. + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle(); + + assertThat(GeneralVariables.voiceCommandsEnabled).isTrue(); + assertThat(GeneralVariables.mutableVoiceCommandsEnabled.getValue()).isTrue(); + } finally { + GeneralVariables.voiceCommandsEnabled = origEnabled; + GeneralVariables.mutableVoiceCommandsEnabled.setValue(origMirror); + } + } + + @Test + public void voiceCommandsEnabled_hydrationTurnsMirrorBackOff() { + boolean origEnabled = GeneralVariables.voiceCommandsEnabled; + Boolean origMirror = GeneralVariables.mutableVoiceCommandsEnabled.getValue(); + try { + GeneralVariables.voiceCommandsEnabled = true; + GeneralVariables.mutableVoiceCommandsEnabled.setValue(true); + + Map config = new LinkedHashMap<>(); + config.put("voiceCommandsEnabled", "0"); + opr.writeConfigSync(config); + + hydrate(); + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle(); + + assertThat(GeneralVariables.voiceCommandsEnabled).isFalse(); + assertThat(GeneralVariables.mutableVoiceCommandsEnabled.getValue()).isFalse(); + } finally { + GeneralVariables.voiceCommandsEnabled = origEnabled; + GeneralVariables.mutableVoiceCommandsEnabled.setValue(origMirror); + } + } + // ---- self-syncing clock (ClockSelfSync) ----------------------------------- // New "autoSyncClockFromDecodes" key: the classic bug this guards against is // adding the field + writeConfig on toggle but forgetting the hydration arm, diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisionsTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisionsTest.java new file mode 100644 index 000000000..44cf7d0fb --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnnouncementDecisionsTest.java @@ -0,0 +1,172 @@ +package com.k1af.ft8af.voice; + +import static com.google.common.truth.Truth.assertThat; + +import com.k1af.ft8af.voice.VoiceAnnouncementDecisions.Kind; + +import org.junit.Test; + +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** Pure-JVM tests for {@link VoiceAnnouncementDecisions} (no Android runtime needed). */ +public class VoiceAnnouncementDecisionsTest { + + private static Set newSpokenSet() { + return Collections.newSetFromMap(new ConcurrentHashMap<>()); + } + + // ---- decide: toggle gating ------------------------------------------ + + @Test + public void decide_callingMe_firesOnlyWhenToggleOn() { + assertThat(VoiceAnnouncementDecisions.decide( + true, false, false, true, false, false, false, false)) + .isEqualTo(Kind.CALLING_ME); + assertThat(VoiceAnnouncementDecisions.decide( + false, false, false, true, false, false, false, false)) + .isNull(); + } + + @Test + public void decide_newDxcc_firesOnlyWhenToggleOnAndCq() { + assertThat(VoiceAnnouncementDecisions.decide( + false, true, false, false, true, true, false, false)) + .isEqualTo(Kind.NEW_DXCC); + // Toggle off + assertThat(VoiceAnnouncementDecisions.decide( + false, false, false, false, true, true, false, false)) + .isNull(); + // Not a CQ — needed-DX announcements are CQ-gated like the alerts. + assertThat(VoiceAnnouncementDecisions.decide( + false, true, false, false, false, true, false, false)) + .isNull(); + } + + @Test + public void decide_newPrefix_firesOnlyWhenToggleOnAndCq() { + assertThat(VoiceAnnouncementDecisions.decide( + false, false, true, false, true, false, true, false)) + .isEqualTo(Kind.NEW_PREFIX); + assertThat(VoiceAnnouncementDecisions.decide( + false, false, false, false, true, false, true, false)) + .isNull(); + assertThat(VoiceAnnouncementDecisions.decide( + false, false, true, false, false, false, true, false)) + .isNull(); + } + + // ---- decide: priority ------------------------------------------------ + + @Test + public void decide_addressedToMeBeatsEverything() { + // All toggles on, message qualifies in every category: calling-me wins. + assertThat(VoiceAnnouncementDecisions.decide( + true, true, true, true, true, true, true, false)) + .isEqualTo(Kind.CALLING_ME); + } + + @Test + public void decide_newDxccBeatsNewPrefix() { + assertThat(VoiceAnnouncementDecisions.decide( + true, true, true, false, true, true, true, false)) + .isEqualTo(Kind.NEW_DXCC); + } + + @Test + public void decide_blockedIsAlwaysSilent() { + assertThat(VoiceAnnouncementDecisions.decide( + true, true, true, true, true, true, true, true)) + .isNull(); + } + + @Test + public void decide_nothingQualifies() { + assertThat(VoiceAnnouncementDecisions.decide( + true, true, true, false, true, false, false, false)) + .isNull(); + } + + // ---- anyDecodeAnnounceEnabled ---------------------------------------- + + @Test + public void anyDecodeAnnounceEnabled_coversEachToggle() { + assertThat(VoiceAnnouncementDecisions.anyDecodeAnnounceEnabled(false, false, false)) + .isFalse(); + assertThat(VoiceAnnouncementDecisions.anyDecodeAnnounceEnabled(true, false, false)) + .isTrue(); + assertThat(VoiceAnnouncementDecisions.anyDecodeAnnounceEnabled(false, true, false)) + .isTrue(); + assertThat(VoiceAnnouncementDecisions.anyDecodeAnnounceEnabled(false, false, true)) + .isTrue(); + } + + // ---- claim: dedup + mute gate ---------------------------------------- + + @Test + public void claim_sameStationAnnouncedOncePerSession() { + Set spoken = newSpokenSet(); + String key = VoiceAnnouncementDecisions.callingMeKey("K1ABC"); + assertThat(VoiceAnnouncementDecisions.claim(spoken, key, true)).isTrue(); + // Same station on the next decode pass / cycle: silent. + assertThat(VoiceAnnouncementDecisions.claim(spoken, key, true)).isFalse(); + } + + @Test + public void claim_doesNotBurnKeyWhileMuted() { + Set spoken = newSpokenSet(); + String key = VoiceAnnouncementDecisions.callingMeKey("K1ABC"); + // TX active — no speech, and the key must survive for the next cycle. + assertThat(VoiceAnnouncementDecisions.claim(spoken, key, false)).isFalse(); + assertThat(spoken).isEmpty(); + // TX over, station still calling: announce now. + assertThat(VoiceAnnouncementDecisions.claim(spoken, key, true)).isTrue(); + } + + @Test + public void claim_differentStationsAreIndependent() { + Set spoken = newSpokenSet(); + assertThat(VoiceAnnouncementDecisions.claim( + spoken, VoiceAnnouncementDecisions.callingMeKey("K1ABC"), true)).isTrue(); + assertThat(VoiceAnnouncementDecisions.claim( + spoken, VoiceAnnouncementDecisions.callingMeKey("W9XYZ"), true)).isTrue(); + } + + // ---- key formats ------------------------------------------------------- + + @Test + public void keys_areNamespacedAndNormalized() { + assertThat(VoiceAnnouncementDecisions.callingMeKey(" k1abc ")) + .isEqualTo("VCALL:K1ABC"); + assertThat(VoiceAnnouncementDecisions.newDxccKey("Japan")) + .isEqualTo("VDXCC:JAPAN"); + assertThat(VoiceAnnouncementDecisions.newPrefixKey("w1")) + .isEqualTo("VPREFIX:W1"); + assertThat(VoiceAnnouncementDecisions.qsoCompleteKey("K1ABC", "2026-08-02 12:00")) + .isEqualTo("VQSO:K1ABC|2026-08-02 12:00"); + } + + @Test + public void keys_nullSafe() { + assertThat(VoiceAnnouncementDecisions.callingMeKey(null)).isEqualTo("VCALL:"); + assertThat(VoiceAnnouncementDecisions.qsoCompleteKey(null, null)).isEqualTo("VQSO:|"); + } + + @Test + public void keys_localeStableUnderTurkishDefaultLocale() { + // Default-locale toUpperCase() in tr-TR maps 'i' -> dotted 'İ', so the + // same station would produce different dedup keys on a Turkish device. + // norm() must use Locale.ROOT. + java.util.Locale original = java.util.Locale.getDefault(); + try { + java.util.Locale.setDefault(new java.util.Locale("tr", "TR")); + assertThat(VoiceAnnouncementDecisions.callingMeKey("ti5abc")) + .isEqualTo("VCALL:TI5ABC"); + assertThat(VoiceAnnouncementDecisions.newDxccKey("Liberia")) + .isEqualTo("VDXCC:LIBERIA"); + } finally { + java.util.Locale.setDefault(original); + } + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnswerSelectorTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnswerSelectorTest.java new file mode 100644 index 000000000..140fb3c97 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceAnswerSelectorTest.java @@ -0,0 +1,86 @@ +package com.k1af.ft8af.voice; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Pure-JVM tests for {@link VoiceAnswerSelector}. The selector is generic, so + * these use a tiny stand-in message type instead of the Android-tied + * {@code Ft8Message}. + */ +public class VoiceAnswerSelectorTest { + + /** Minimal decode stand-in: sender + whether it's addressed to me. */ + private static final class Msg { + final String from; + final boolean toMe; + + Msg(String from, boolean toMe) { + this.from = from; + this.toMe = toMe; + } + } + + private static Msg pick(List decodes, String queueHead) { + return VoiceAnswerSelector.pick(decodes, m -> m.toMe, m -> m.from, queueHead); + } + + @Test + public void callingMeBeatsQueueHead() { + Msg queued = new Msg("W9XYZ", false); + Msg callingMe = new Msg("K1ABC", true); + assertThat(pick(Arrays.asList(queued, callingMe), "W9XYZ")).isSameInstanceAs(callingMe); + } + + @Test + public void newestCallingMeWins() { + Msg older = new Msg("K1ABC", true); + Msg newer = new Msg("W9XYZ", true); + // List is oldest-first; the selector must take the newest (last). + assertThat(pick(Arrays.asList(older, newer), null)).isSameInstanceAs(newer); + } + + @Test + public void fallsBackToQueueHeadNewestDecode() { + Msg other = new Msg("N0CAL", false); + Msg queuedOld = new Msg("W9XYZ", false); + Msg queuedNew = new Msg("W9XYZ", false); + assertThat(pick(Arrays.asList(queuedOld, other, queuedNew), "W9XYZ")) + .isSameInstanceAs(queuedNew); + } + + @Test + public void queueHeadMatchIsCaseInsensitiveAndTrimmed() { + Msg queued = new Msg("w9xyz", false); + assertThat(pick(Collections.singletonList(queued), " W9XYZ ")).isSameInstanceAs(queued); + } + + @Test + public void emptyListYieldsNull() { + assertThat(pick(new ArrayList<>(), "W9XYZ")).isNull(); + assertThat(pick(null, "W9XYZ")).isNull(); + } + + @Test + public void noCandidateYieldsNull() { + Msg other = new Msg("N0CAL", false); + assertThat(pick(Collections.singletonList(other), null)).isNull(); + // Queue head not present in the decode list either. + assertThat(pick(Collections.singletonList(other), "W9XYZ")).isNull(); + } + + @Test + public void nullAndSenderlessRowsAreSkipped() { + Msg callingMe = new Msg("K1ABC", true); + Msg blankSender = new Msg(" ", true); + Msg nullSender = new Msg(null, true); + List decodes = Arrays.asList(callingMe, null, blankSender, nullSender); + assertThat(pick(decodes, null)).isSameInstanceAs(callingMe); + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceCommandParserTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceCommandParserTest.java new file mode 100644 index 000000000..29d637c02 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoiceCommandParserTest.java @@ -0,0 +1,120 @@ +package com.k1af.ft8af.voice; + +import static com.google.common.truth.Truth.assertThat; + +import com.k1af.ft8af.voice.VoiceCommandParser.Command; + +import org.junit.Test; + +/** Pure-JVM tests for {@link VoiceCommandParser} (no Android runtime needed). */ +public class VoiceCommandParserTest { + + // ---- ANSWER -------------------------------------------------------- + + @Test + public void answer_plain() { + assertThat(VoiceCommandParser.parse("answer")).isEqualTo(Command.ANSWER); + } + + @Test + public void answer_withNoiseWords() { + assertThat(VoiceCommandParser.parse("answer him")).isEqualTo(Command.ANSWER); + assertThat(VoiceCommandParser.parse("answer them")).isEqualTo(Command.ANSWER); + assertThat(VoiceCommandParser.parse("please answer the station")).isEqualTo(Command.ANSWER); + } + + @Test + public void answer_replyVariant() { + assertThat(VoiceCommandParser.parse("reply")).isEqualTo(Command.ANSWER); + assertThat(VoiceCommandParser.parse("reply to him")).isEqualTo(Command.ANSWER); + } + + @Test + public void answer_capitalizationAndPunctuation() { + assertThat(VoiceCommandParser.parse(" Answer! ")).isEqualTo(Command.ANSWER); + } + + // ---- CALL_CQ ------------------------------------------------------- + + @Test + public void callCq_variants() { + assertThat(VoiceCommandParser.parse("call cq")).isEqualTo(Command.CALL_CQ); + assertThat(VoiceCommandParser.parse("cq")).isEqualTo(Command.CALL_CQ); + assertThat(VoiceCommandParser.parse("CQ")).isEqualTo(Command.CALL_CQ); + assertThat(VoiceCommandParser.parse("start calling CQ")).isEqualTo(Command.CALL_CQ); + } + + @Test + public void callCq_recognizerMondegreens() { + // Recognizers routinely transcribe "CQ" as "seek you" or spelled letters. + assertThat(VoiceCommandParser.parse("call seek you")).isEqualTo(Command.CALL_CQ); + assertThat(VoiceCommandParser.parse("call c q")).isEqualTo(Command.CALL_CQ); + } + + // ---- STOP ---------------------------------------------------------- + + @Test + public void stop_variants() { + assertThat(VoiceCommandParser.parse("stop")).isEqualTo(Command.STOP); + assertThat(VoiceCommandParser.parse("stop transmitting")).isEqualTo(Command.STOP); + assertThat(VoiceCommandParser.parse("halt")).isEqualTo(Command.STOP); + assertThat(VoiceCommandParser.parse("cancel")).isEqualTo(Command.STOP); + } + + @Test + public void stop_beatsCqWhenBothPresent() { + // "stop calling cq" must stop, not start a CQ run. + assertThat(VoiceCommandParser.parse("stop calling cq")).isEqualTo(Command.STOP); + } + + // ---- SKIP ---------------------------------------------------------- + + @Test + public void skip_variants() { + assertThat(VoiceCommandParser.parse("skip")).isEqualTo(Command.SKIP); + assertThat(VoiceCommandParser.parse("skip him")).isEqualTo(Command.SKIP); + assertThat(VoiceCommandParser.parse("next")).isEqualTo(Command.SKIP); + } + + // ---- LOG ----------------------------------------------------------- + + @Test + public void log_variants() { + assertThat(VoiceCommandParser.parse("log it")).isEqualTo(Command.LOG); + assertThat(VoiceCommandParser.parse("log")).isEqualTo(Command.LOG); + assertThat(VoiceCommandParser.parse("logged")).isEqualTo(Command.LOG); + } + + // ---- UNKNOWN ------------------------------------------------------- + + @Test + public void unknown_forUnrelatedSpeech() { + assertThat(VoiceCommandParser.parse("what's the weather")).isEqualTo(Command.UNKNOWN); + assertThat(VoiceCommandParser.parse("hello world")).isEqualTo(Command.UNKNOWN); + // "call" alone is not a command — only "call cq" is. + assertThat(VoiceCommandParser.parse("call")).isEqualTo(Command.UNKNOWN); + } + + @Test + public void unknown_forNullEmptyAndPunctuationOnly() { + assertThat(VoiceCommandParser.parse(null)).isEqualTo(Command.UNKNOWN); + assertThat(VoiceCommandParser.parse("")).isEqualTo(Command.UNKNOWN); + assertThat(VoiceCommandParser.parse(" ")).isEqualTo(Command.UNKNOWN); + assertThat(VoiceCommandParser.parse("!?.,")).isEqualTo(Command.UNKNOWN); + } + + @Test + public void unknown_keywordsMustBeWholeTokens() { + // Keyword embedded in a longer word must not match ("nextel" != "next"). + assertThat(VoiceCommandParser.parse("nextel")).isEqualTo(Command.UNKNOWN); + assertThat(VoiceCommandParser.parse("catalog")).isEqualTo(Command.UNKNOWN); + assertThat(VoiceCommandParser.parse("unstoppable")).isEqualTo(Command.UNKNOWN); + } + + // ---- normalize ----------------------------------------------------- + + @Test + public void normalize_stripsPunctuationAndCollapsesWhitespace() { + assertThat(VoiceCommandParser.normalize(" Call, CQ! now ")).isEqualTo("call cq now"); + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoicePhrasesTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoicePhrasesTest.java new file mode 100644 index 000000000..dd73d4e10 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/voice/VoicePhrasesTest.java @@ -0,0 +1,100 @@ +package com.k1af.ft8af.voice; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** Pure-JVM tests for {@link VoicePhrases} (no Android runtime needed). */ +public class VoicePhrasesTest { + + // ---- spellCallsign --------------------------------------------------- + + @Test + public void spellCallsign_spacesEveryCharacter() { + assertThat(VoicePhrases.spellCallsign("K1ABC")).isEqualTo("K 1 A B C"); + } + + @Test + public void spellCallsign_slashSpokenAsStroke() { + assertThat(VoicePhrases.spellCallsign("EA8/K1ABC")) + .isEqualTo("E A 8 stroke K 1 A B C"); + } + + @Test + public void spellCallsign_nullAndBlankAreEmpty() { + assertThat(VoicePhrases.spellCallsign(null)).isEmpty(); + assertThat(VoicePhrases.spellCallsign(" ")).isEmpty(); + } + + // ---- snrPhrase --------------------------------------------------------- + + @Test + public void snrPhrase_negative() { + assertThat(VoicePhrases.snrPhrase(-5)).isEqualTo("minus 5"); + } + + @Test + public void snrPhrase_positive() { + assertThat(VoicePhrases.snrPhrase(3)).isEqualTo("plus 3"); + } + + @Test + public void snrPhrase_zero() { + assertThat(VoicePhrases.snrPhrase(0)).isEqualTo("zero"); + } + + @Test + public void snrPhrase_unknownSentinelIsEmpty() { + // Decoder can emit a message with no SNR (Integer.MIN_VALUE sentinel); + // the phrase must not say "minus 2147483648". + assertThat(VoicePhrases.snrPhrase(VoicePhrases.SNR_UNKNOWN)).isEmpty(); + } + + @Test + public void snrUnknownSentinelMatchesFt8Message() { + // VoicePhrases mirrors Ft8Message.SNR_UNKNOWN without importing it + // (keeps this class Android-free); pin the value so they can't drift. + assertThat(VoicePhrases.SNR_UNKNOWN).isEqualTo(Integer.MIN_VALUE); + } + + // ---- announcement phrases ------------------------------------------------ + + @Test + public void callingYou_withSnr() { + assertThat(VoicePhrases.callingYou("K1ABC", -5)) + .isEqualTo("K 1 A B C calling you, minus 5"); + } + + @Test + public void callingYou_unknownSnrDropsClause() { + assertThat(VoicePhrases.callingYou("K1ABC", VoicePhrases.SNR_UNKNOWN)) + .isEqualTo("K 1 A B C calling you"); + } + + @Test + public void qsoLogged() { + assertThat(VoicePhrases.qsoLogged("K1ABC")) + .isEqualTo("QSO with K 1 A B C logged"); + } + + @Test + public void newCountry_speaksResolvedNameVerbatim() { + assertThat(VoicePhrases.newCountry("Japan")).isEqualTo("New country: Japan"); + } + + @Test + public void newPrefix_isSpelled() { + assertThat(VoicePhrases.newPrefix("W1")).isEqualTo("New prefix: W 1"); + } + + // ---- echo confirmations --------------------------------------------------- + + @Test + public void echoes() { + assertThat(VoicePhrases.echoCallingCq()).isEqualTo("Calling CQ"); + assertThat(VoicePhrases.echoStopping()).isEqualTo("Stopping"); + assertThat(VoicePhrases.echoSkipping()).isEqualTo("Back to CQ"); + assertThat(VoicePhrases.echoLogged()).isEqualTo("Logged"); + assertThat(VoicePhrases.echoAnswering("K1ABC")).isEqualTo("Answering K 1 A B C"); + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/wave/HamRecorderMicGateTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/wave/HamRecorderMicGateTest.java new file mode 100644 index 000000000..6854f8e0c --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/HamRecorderMicGateTest.java @@ -0,0 +1,35 @@ +package com.k1af.ft8af.wave; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** + * Pure-JVM tests for {@link HamRecorder#phoneMicCaptureInUse} — the decision + * behind the voice-command button's "phone mic in use by FT8 RX" gate. + */ +public class HamRecorderMicGateTest { + + @Test + public void systemMicCaptureBlocksVoiceCommands() { + // Running, mic source, AudioRecord path (incl. Android-routed USB input). + assertThat(HamRecorder.phoneMicCaptureInUse(true, true, false)).isTrue(); + } + + @Test + public void directUsbCaptureLeavesMicFree() { + // Direct-libusb USB audio: no AudioRecord exists, recognizer is safe. + assertThat(HamRecorder.phoneMicCaptureInUse(true, true, true)).isFalse(); + } + + @Test + public void lanAudioSourceLeavesMicFree() { + // ICOM WiFi / Flex network audio: MicRecorder is stopped. + assertThat(HamRecorder.phoneMicCaptureInUse(true, false, false)).isFalse(); + } + + @Test + public void stoppedRecorderLeavesMicFree() { + assertThat(HamRecorder.phoneMicCaptureInUse(false, true, false)).isFalse(); + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButtonLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButtonLogicTest.kt new file mode 100644 index 000000000..f516b73f4 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/VoiceCommandButtonLogicTest.kt @@ -0,0 +1,92 @@ +package radio.ks3ckc.ft8af.ui.components + +import android.speech.SpeechRecognizer +import com.google.common.truth.Truth.assertThat +import com.k1af.ft8af.R +import com.k1af.ft8af.voice.VoiceCommandParser +import org.junit.Test + +/** + * Pure-JVM tests for the voice-command button's extracted decision logic + * (the Composable itself is a thin wrapper): visibility/enabled gating and + * the spoken echo mapping. + */ +class VoiceCommandButtonLogicTest { + // ---- voiceButtonState ------------------------------------------------- + + @Test + fun hiddenWheneverSettingIsOff() { + assertThat(voiceButtonState(commandsEnabled = false, phoneMicInUse = false)) + .isEqualTo(VoiceButtonState.HIDDEN) + // Setting off wins even when the mic would also be busy. + assertThat(voiceButtonState(commandsEnabled = false, phoneMicInUse = true)) + .isEqualTo(VoiceButtonState.HIDDEN) + } + + @Test + fun blockedWhilePhoneMicCapturesFt8Audio() { + assertThat(voiceButtonState(commandsEnabled = true, phoneMicInUse = true)) + .isEqualTo(VoiceButtonState.BLOCKED_MIC) + } + + @Test + fun readyWhenEnabledAndMicFree() { + assertThat(voiceButtonState(commandsEnabled = true, phoneMicInUse = false)) + .isEqualTo(VoiceButtonState.READY) + } + + // ---- echoPhraseFor ------------------------------------------------------ + + @Test + fun echoesForEachCommand() { + assertThat(echoPhraseFor(VoiceCommandParser.Command.CALL_CQ, null)) + .isEqualTo("Calling CQ") + assertThat(echoPhraseFor(VoiceCommandParser.Command.STOP, null)) + .isEqualTo("Stopping") + assertThat(echoPhraseFor(VoiceCommandParser.Command.SKIP, null)) + .isEqualTo("Back to CQ") + assertThat(echoPhraseFor(VoiceCommandParser.Command.LOG, null)) + .isEqualTo("Logged") + } + + @Test + fun answerEchoSpellsTheCallsign() { + assertThat(echoPhraseFor(VoiceCommandParser.Command.ANSWER, "K1ABC")) + .isEqualTo("Answering K 1 A B C") + } + + @Test + fun answerWithoutCandidateHasNoEcho() { + assertThat(echoPhraseFor(VoiceCommandParser.Command.ANSWER, null)).isNull() + } + + @Test + fun unknownHasNoEcho() { + assertThat(echoPhraseFor(VoiceCommandParser.Command.UNKNOWN, null)).isNull() + } + + // ---- recognizer error -> toast mapping --------------------------------- + + @Test + fun errorClientIsSilent() { + // ERROR_CLIENT is what cancel() (second tap) produces — a deliberate + // user action must not be toasted as a failure. + assertThat(voiceErrorToastRes(SpeechRecognizer.ERROR_CLIENT)).isNull() + } + + @Test + fun noMatchAndTimeoutToastNotUnderstood() { + assertThat(voiceErrorToastRes(SpeechRecognizer.ERROR_NO_MATCH)) + .isEqualTo(R.string.voice_not_understood) + assertThat(voiceErrorToastRes(SpeechRecognizer.ERROR_SPEECH_TIMEOUT)) + .isEqualTo(R.string.voice_not_understood) + } + + @Test + fun otherErrorsToastGenericMessage() { + assertThat(voiceErrorToastRes(SpeechRecognizer.ERROR_NETWORK)) + .isEqualTo(R.string.voice_recognizer_error) + assertThat(voiceErrorToastRes(SpeechRecognizer.ERROR_AUDIO)) + .isEqualTo(R.string.voice_recognizer_error) + } +}