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/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] 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 diff --git a/ft8af/app/src/main/AndroidManifest.xml b/ft8af/app/src/main/AndroidManifest.xml index f03c01e30..4a3879e94 100644 --- a/ft8af/app/src/main/AndroidManifest.xml +++ b/ft8af/app/src/main/AndroidManifest.xml @@ -29,6 +29,21 @@ (Android 14 mutes background mic without a microphone-typed foreground service). --> + + + + + + + + + + + + + + + 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..e7f136c1d --- /dev/null +++ b/ft8af/app/src/main/cpp/ft8af_glue/hamlib_feed.h @@ -0,0 +1,67 @@ +// 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, 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. 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) +{ + 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) + { + if (w < 0 && errno == EINTR) + continue; // interrupted before any byte moved — retry, don't truncate + 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..c79fd7898 --- /dev/null +++ b/ft8af/app/src/main/cpp/ft8af_glue/test_hamlib_feed.c @@ -0,0 +1,271 @@ +// 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. +// 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" + +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; +} + +// --- 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, + 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"); + } + + // 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"); + return 0; + } + printf("test_hamlib_feed: %d FAILURE(S)\n", g_failures); + return 1; +} 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/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 65bd69302..afc3bcf52 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 @@ -65,6 +71,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 @@ -210,6 +226,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}. @@ -287,6 +312,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). @@ -407,6 +463,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 @@ -439,6 +497,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 @@ -447,9 +506,10 @@ 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. 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 @@ -483,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; @@ -495,27 +562,66 @@ 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. + 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. + 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. 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<>(); - 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) 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(). 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 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) @@ -534,6 +640,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<>(); @@ -578,6 +689,23 @@ public static String excludedBandsToCsv() { public static boolean alertOnCqReply = false; public static boolean alertOnQsoComplete = false; + // Voice assistant (Settings → Voice Assistant). All opt-in, default off. + // - voiceAnnounce*: spoken TTS announcements (station calling me, QSO logged, + // new-DXCC CQ, new-prefix CQ) — see voice/VoiceAnnouncer + DxAlertNotifier. + // - voiceCommandsEnabled: shows the push-to-talk mic button on the main screen + // for one-shot voice commands (answer / call CQ / stop / skip / log it). + public static boolean voiceAnnounceCalling = false; + public static boolean voiceAnnounceQsoComplete = false; + public static boolean voiceAnnounceNewDxcc = false; + public static boolean voiceAnnounceNewPrefix = false; + public static boolean voiceCommandsEnabled = false; + // Observable mirror of voiceCommandsEnabled: the mic button lives on the main + // screen, which is already composed when the settings toggle flips — a plain + // static read there never recomposes, so the button only appeared after an + // app restart. Settings writes and config hydration must update both. + public static final MutableLiveData 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")); @@ -624,7 +752,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 @@ -702,6 +842,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; } @@ -831,6 +1002,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/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java index 2c8dd49de..3ff38bbb5 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; @@ -68,8 +69,10 @@ 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.FastPassDisposition; import com.k1af.ft8af.ft8listener.FT8SignalListener; import com.k1af.ft8af.ft8listener.OnFt8Listen; import com.k1af.ft8af.ft8transmit.FT8TransmitSignal; @@ -88,6 +91,8 @@ 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.RigDialTarget; import com.k1af.ft8af.rigs.CatLiveness; import com.k1af.ft8af.rigs.DiscoveryTX500Rig; import com.k1af.ft8af.rigs.ElecraftRig; @@ -115,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; @@ -165,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 @@ -207,8 +219,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 @@ -236,6 +246,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 @@ -285,6 +298,23 @@ public void onConnected() { //connected to rig setCatConnectionState(CatConnectionState.CONNECTED); ToastMessage.show(getStringFromResource(R.string.connected_rig)); + // 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. + // + // 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"). @@ -321,6 +351,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); @@ -577,12 +621,29 @@ 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); } @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 @@ -613,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 @@ -662,12 +759,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 @@ -689,15 +800,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); @@ -709,12 +817,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( @@ -797,6 +912,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); } } @@ -809,6 +928,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(); } @@ -845,10 +969,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)); } } } @@ -874,9 +999,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 @@ -924,11 +1048,35 @@ 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); 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(); @@ -1196,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 ------------------------------------------------- /** @@ -1252,6 +1447,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; @@ -1458,8 +1677,33 @@ 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; + /** 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 + * 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()) { @@ -1467,7 +1711,31 @@ public void setOperationBand() { return; } - fileLog("setOperationBand: sending USB mode, then freq=" + GeneralVariables.band + long nowMs = System.currentTimeMillis(); + // 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)) { + // 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=" + dialHz + + " already set) caller=" + RetunePolicy.callerOf( + Thread.currentThread().getStackTrace(), + MainViewModel.class.getName())); + lastRetuneSuppressionLogAtMs = nowMs; + suppressedRetunes = 0; + } + return; + } + lastPushedBandFreq = dialHz; + lastBandPushAtMs = nowMs; + + 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 @@ -1476,9 +1744,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); @@ -1533,15 +1806,28 @@ 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; + // 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); - 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) { @@ -1625,6 +1911,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); @@ -1972,6 +2263,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: @@ -2109,14 +2434,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() { @@ -2128,10 +2461,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/main/java/com/k1af/ft8af/MessageHashMap.java b/ft8af/app/src/main/java/com/k1af/ft8af/MessageHashMap.java index b11b39b8c..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,21 +7,38 @@ 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"; /** - * 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; } @@ -43,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/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/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/alert/AlertDecisions.java b/ft8af/app/src/main/java/com/k1af/ft8af/alert/AlertDecisions.java index ed00a123a..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 @@ -42,6 +66,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..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; @@ -30,7 +34,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}) @@ -52,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; @@ -65,7 +81,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"); } @@ -94,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.alertOnCqReply && !GeneralVariables.hasWatchCallsigns() + && !anyVoice) { return; } @@ -103,6 +124,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. @@ -115,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; @@ -131,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(); @@ -153,16 +253,36 @@ public void notifyQsoComplete(QSLRecord qslRecord) { body.toString(), call, qslRecord.getBandFreq()); } - private static String defaultBody(Ft8Message msg) { + /** "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()) { 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"); @@ -176,16 +296,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, @@ -213,7 +334,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/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/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/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/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..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; } @@ -140,6 +168,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 +185,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 +206,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; } @@ -209,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/CableSerialPort.java b/ft8af/app/src/main/java/com/k1af/ft8af/connector/CableSerialPort.java index b647d758f..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 @@ -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; } @@ -294,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/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/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/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 44480629c..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 @@ -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; @@ -37,6 +38,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; @@ -50,6 +52,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"; @@ -59,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; } @@ -270,6 +288,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" + @@ -298,7 +323,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 } @@ -574,6 +601,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) { @@ -609,8 +673,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 +698,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 +722,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(); @@ -1200,145 +1261,83 @@ 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(); - 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("%s " - , cursor.getString(cursor.getColumnIndex("gridsquare")).length() - , 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("MFSK %s " - , submode.length(), submode)); - } else { - logStr.append(String.format("%s " - , mode.length(), mode)); - } - } - - if (cursor.getString(cursor.getColumnIndex("rst_sent")) != null) { - logStr.append(String.format("%s " - , cursor.getString(cursor.getColumnIndex("rst_sent")).length() - , 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() - , 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() - , 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() - , 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() - , 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() - , cursor.getString(cursor.getColumnIndex("time_off")))); - } - - if (cursor.getString(cursor.getColumnIndex("band")) != null) { - logStr.append(String.format("%s " - , cursor.getString(cursor.getColumnIndex("band")).length() - , cursor.getString(cursor.getColumnIndex("band")))); - } - - if (cursor.getString(cursor.getColumnIndex("freq")) != null) { - logStr.append(String.format("%s " - , cursor.getString(cursor.getColumnIndex("freq")).length() - , 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() - , 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() - , 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() - , 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")); - - //Distance: 99 km - //When writing to db, must append " km" - logStr.append(String.format("%s \n" - , comment.length() - , 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")) + .myLat(colDouble(cursor, "my_lat")) + .myLon(colDouble(cursor, "my_lon")) + .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("<%s:%d>%s ", adifName, value.length(), value)); + 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); } /** @@ -1509,6 +1508,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 + // 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. + // + // 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" + @@ -1558,7 +1574,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) @@ -1581,7 +1598,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 " @@ -1592,6 +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); + // 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 RotaTripManager.onQsoLogged). + radio.ks3ckc.ft8af.rota.RotaTripManager.onQsoLogged(record); } if (afterInsertQSLData!=null){ afterInsertQSLData.doAfterInsert(false,true);//New QSL @@ -2344,11 +2370,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()) { @@ -2361,11 +2394,12 @@ 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<>?"; - 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 +2420,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()) { @@ -2420,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. @@ -2669,7 +2724,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; } @@ -2682,6 +2740,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); } @@ -2704,6 +2764,16 @@ 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. + GeneralVariables.decodeSortMode = parseConfigInt(result, 0); + } + if (name.equalsIgnoreCase("clearOnBandModeChange")) { GeneralVariables.clearOnBandModeChange = result.equals("1"); } @@ -2730,9 +2800,22 @@ 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"); } + 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"); } @@ -2745,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); @@ -2777,6 +2863,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"); } @@ -2863,6 +2958,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"); } @@ -2898,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); } @@ -2939,7 +3056,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"); @@ -3016,6 +3138,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"); @@ -3059,9 +3184,18 @@ 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("highlightNewState")) { + GeneralVariables.highlightNewState = result.equals("1"); + } 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"); } @@ -3080,10 +3214,16 @@ 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"); } + if (name.equalsIgnoreCase("showBeamHeading")) { + GeneralVariables.showBeamHeading = result.equals("1"); + } } 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/database/WorkedModeFilter.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/WorkedModeFilter.java new file mode 100644 index 000000000..daee60b96 --- /dev/null +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/WorkedModeFilter.java @@ -0,0 +1,81 @@ +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)}); + } + + /** + * 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. + * + * @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/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/main/java/com/k1af/ft8af/ft8listener/FT8SignalListener.java b/ft8af/app/src/main/java/com/k1af/ft8af/ft8listener/FT8SignalListener.java index e4efd00c2..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(); } @@ -251,29 +286,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 +339,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 +375,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/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: + * + * + * + * + * + *
    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/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/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/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/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 0e33b22bf..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; @@ -121,6 +122,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; @@ -134,6 +148,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<>(); @@ -249,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 } } @@ -261,8 +301,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 +364,203 @@ 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; + + /** + * 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. + * + *

    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 + * 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. */ @@ -373,10 +609,17 @@ 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 + " 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)); @@ -406,6 +649,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; @@ -541,6 +790,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) { @@ -755,6 +1005,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 +1139,36 @@ 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 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); + } finally { + teardown.run(); + } } /** @@ -1007,10 +1301,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); } @@ -1313,6 +1694,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) @@ -1332,6 +1715,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 @@ -1378,6 +1763,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 @@ -1398,9 +1789,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); @@ -1522,6 +1921,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; } @@ -1545,11 +1968,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; } @@ -1557,10 +1981,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; @@ -1573,9 +2007,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"); @@ -1610,6 +2053,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 @@ -1927,6 +2381,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); @@ -2270,6 +2726,77 @@ 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. + * + *

    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 @@ -2295,6 +2822,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 @@ -2326,7 +2855,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 @@ -2722,15 +3256,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 @@ -2741,9 +3267,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) { @@ -2776,24 +3300,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/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/main/java/com/k1af/ft8af/html/HtmlContext.java b/ft8af/app/src/main/java/com/k1af/ft8af/html/HtmlContext.java index 83c1c8d98..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 @@ -66,6 +66,118 @@ public static String HTML_STRING(String context) { return HTML_HEAD + HTML_BODY(context); } + /** + * Escape a request-derived value for safe inclusion in generated HTML, in + * both element content and quoted-attribute contexts. Escapes the five + * markup-significant characters {@code & < > " '}; {@code null} becomes "". + * + *

    This is the single central escaper for user-supplied web-logbook input. + * It replaces the ad-hoc {@code .replace("<", "<")} calls scattered + * through {@link LogHttpServer}, which missed {@code "}, {@code >}, and + * {@code &} and so left attribute-context breakout (e.g. a + * {@code ">"); + 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"); + } + + // ---- 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>\n"); + } + + @Test + public void tableCellEscaped_nullValueBecomesEmptyCell() { + StringBuilder sb = new StringBuilder(); + HtmlContext.tableCellEscaped(sb, (String) null); + assertThat(sb.toString()).isEqualTo("\n"); + } +} 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; + } +} 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("K1ABC, \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")).isEqualTo(1); + assertThat(html).contains("C10, "); + assertThat(html).endsWith("\n\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")).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("\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(); + } + } +} 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(""); + } +} 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(); + } +} 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..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 @@ -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,43 @@ 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); + } + + // ---- 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, 10_000L - 2_200L, 5000L * MS, 5000L * MS, 10_000L); + assertThat(r).isEqualTo(-2_200); + } + + @Test + 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 ---- @Test 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..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. @@ -134,4 +183,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 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_ROTA_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/java/com/k1af/ft8af/log/AdifRecordTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifRecordTest.java index f6c2b3d08..6d0c9b9e7 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifRecordTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/log/AdifRecordTest.java @@ -20,7 +20,7 @@ public class AdifRecordTest { /** Matches an ADIF field: name, declared length, then value up to the trailing space. */ private static final Pattern FIELD = - Pattern.compile("<([A-Za-z_]+):(\\d+)>([^<]*?) (?=<|$)"); + 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/ImportSharedLogsTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/log/ImportSharedLogsTest.java index 601c0ad96..d85e2d7a2 100644 --- a/ft8af/app/src/test/java/com/k1af/ft8af/log/ImportSharedLogsTest.java +++ b/ft8af/app/src/test/java/com/k1af/ft8af/log/ImportSharedLogsTest.java @@ -6,6 +6,7 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.Locale; /** * Pure-JVM coverage for the ADIF field-length handling in the shared-log @@ -52,18 +53,16 @@ public void declaredLengthLongerThanValue_keepsWholeValue() { @Test public void zeroLengthValue_doesNotDiscardRecordOrCrash() { - // A stray '>' 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++) { 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..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 @@ -95,6 +95,172 @@ 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_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<>(); + 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_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/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/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..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 @@ -127,6 +153,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 @@ -316,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; @@ -402,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); + } + } } 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); + } + } +} 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..f7f5e9411 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/CatLineSplitterTest.java @@ -0,0 +1,204 @@ +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 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 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' + // 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(); + } + + // --- 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); + } +} 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/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); 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..7cede35b7 --- /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_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(); + + // 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/GuoHeCheckHeadTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/GuoHeCheckHeadTest.java new file mode 100644 index 000000000..b077a21ef --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/GuoHeCheckHeadTest.java @@ -0,0 +1,95 @@ +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 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 + (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); + } +} 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..95d4d3174 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/rigs/RetunePolicyTest.java @@ -0,0 +1,236 @@ +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(); + } + + // --------------------------------------------------------------- + // 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 + // 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"); + } +} 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); + } +} 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 + } + } } 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)) 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); 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"); + } +} 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..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 @@ -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 @@ -120,4 +121,52 @@ 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); + } + + @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); + } } 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)); + } +} 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(); + } +} 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(); + } +} 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..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 @@ -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,81 @@ 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 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 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()); + } +} 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"); + } +} diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ui/ToastMessageTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ui/ToastMessageTest.java new file mode 100644 index 000000000..de0b5560f --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ui/ToastMessageTest.java @@ -0,0 +1,97 @@ +package com.k1af.ft8af.ui; + +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.Shadows.shadowOf; + +import android.os.Looper; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Unit tests for {@link ToastMessage}, covering the crash fixed in this PR: a + * {@code null} debug message (e.g. from {@code ToastMessage.show(e.getMessage())} + * where {@link Throwable#getMessage()} returns {@code null}) used to be stored in + * the internal list, and the delayed cleanup runnable then NPE'd calling + * {@code debugList.get(i).equals(info)} on the null element (Sentry FT8AF-3). + */ +@RunWith(RobolectricTestRunner.class) +public class ToastMessageTest { + + @Test + public void removeFirstMatch_removesMatchingEntry() { + List 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. + } +} 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); + } +} 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); + } +} 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/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..bfd0ddd8d --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/wave/TxUpsamplerTest.java @@ -0,0 +1,146 @@ +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 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 + * 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); + } +} 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); + } +} 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; + } + } +} 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() + } +} 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/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..142396792 --- /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 roadsontheair.com 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. + // roadsontheair.com 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/pskreporter/PskReporterSenderTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/pskreporter/PskReporterSenderTest.kt index 60a37f572..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 @@ -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 // --------------------------------------------------------------- @@ -598,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() } 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..c67833593 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/qrz/LruCacheTest.kt @@ -0,0 +1,159 @@ +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 +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 { + // 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() + + // 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() + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/HighwayCachePolicyTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/HighwayCachePolicyTest.kt new file mode 100644 index 000000000..1d876dbce --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/HighwayCachePolicyTest.kt @@ -0,0 +1,101 @@ +package radio.ks3ckc.ft8af.rota + +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/rota/HighwayClassifierTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/HighwayClassifierTest.kt new file mode 100644 index 000000000..c4d3ff782 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/HighwayClassifierTest.kt @@ -0,0 +1,98 @@ +package radio.ks3ckc.ft8af.rota + +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/rota/RoadTripScreenLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RoadTripScreenLogicTest.kt new file mode 100644 index 000000000..92b8111c2 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RoadTripScreenLogicTest.kt @@ -0,0 +1,93 @@ +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.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 + * 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("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 = "" + 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 = + 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 = 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 = + RotaTripService.notificationText( + RotaTripState(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 = + 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/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/rota/RotaCallsignTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaCallsignTest.kt new file mode 100644 index 000000000..6a409e39f --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaCallsignTest.kt @@ -0,0 +1,56 @@ +package radio.ks3ckc.ft8af.rota + +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 RotaCallsignTest { + 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/rota/RotaClientTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaClientTest.kt new file mode 100644 index 000000000..ead692ac1 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaClientTest.kt @@ -0,0 +1,281 @@ +package radio.ks3ckc.ft8af.rota + +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 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 RotaClientTest { + 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":"rota_abc123"}"""), + ) + val result = RotaClient.registerOperator(baseUrl, "k1af", homeGrid = "DM79") + + assertThat(result.getOrNull()).isEqualTo("rota_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 = RotaClient.registerOperator(baseUrl, "K1AF").exceptionOrNull() + + 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(isRetryableRotaFailure(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 = + RotaClient.createTrip( + baseUrl = baseUrl, + apiKey = "rota_key", + name = "Route 66", + startTimeMs = 1_753_970_709_000L, + privacy = "delayed", + ).getOrNull() + + assertThat(handle).isEqualTo(RotaTripHandle("trip-1", "tok")) + val request = server.takeRequest() + 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") + 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 = + 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 = RotaClient.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 = 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(RotaClient.completeTrip(baseUrl, "k", "trip-1").isSuccess).isTrue() + assertThat(server.takeRequest().path).isEqualTo("/api/trips/trip-1/complete") + } + + @Test + fun `announceTrip posts a planned trip to the trips endpoint`() = + runBlocking { + server.enqueue(MockResponse().setResponseCode(201).setBody("""{"id":"plan-1"}""")) + val id = + RotaClient.announceTrip( + baseUrl = baseUrl, + apiKey = "k", + name = "I-70 westbound", + startTimeMs = 1_753_970_709_000L, + bands = listOf("20m", "40m"), + modes = listOf("FT8"), + ).getOrNull() + + 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() + 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(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(rotaBackoffMs(50)).isEqualTo(15 * 60_000L) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaCqModifierTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaCqModifierTest.kt new file mode 100644 index 000000000..2141f8668 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaCqModifierTest.kt @@ -0,0 +1,74 @@ +package radio.ks3ckc.ft8af.rota + +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: 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 token to something that actually encodes. + */ +class RotaCqModifierTest { + @Test + 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 `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 + 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 "ROTA" as the value to + // restore would leave the road-trip token set forever after the trip ended. + assertThat(modifierToRemember(ROTA_CQ_MODIFIER)).isEqualTo("") + } + + @Test + fun `ending a trip restores what the operator had before it`() { + 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 "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/rota/RotaPayloadTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaPayloadTest.kt new file mode 100644 index 000000000..4cc0c5ffc --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaPayloadTest.kt @@ -0,0 +1,193 @@ +package radio.ks3ckc.ft8af.rota + +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 RotaPayloadTest { + 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/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() + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaQsoMapperTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaQsoMapperTest.kt new file mode 100644 index 000000000..97200bd25 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaQsoMapperTest.kt @@ -0,0 +1,162 @@ +package radio.ks3ckc.ft8af.rota + +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 RotaQsoMapperTest { + private val now = 1_753_970_709_000L + + @Test + fun `a normal contact maps every field`() { + val qso = + RotaQsoMapper.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 = + RotaQsoMapper.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 = + RotaQsoMapper.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 = + RotaQsoMapper.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 = + RotaQsoMapper.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 = + RotaQsoMapper.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/rota/RotaQueueTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaQueueTest.kt new file mode 100644 index 000000000..cd41b6da3 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaQueueTest.kt @@ -0,0 +1,146 @@ +package radio.ks3ckc.ft8af.rota + +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 RotaQueueTest { + @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(), "rota_queue.json") + + @Test + fun `pruning drops only the contacts the server reported holding`() { + val q = RotaQueue(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() + RotaQueue(file).apply { + repeat(3) { addQso(qso(it)) } + pruneAcknowledgedQsos(setOf(qso(0).dedupeKey())) + } + 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 = 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) + assertThat(q.qsoCount()).isEqualTo(2) + } + + @Test + fun `counts track what was added`() { + val q = RotaQueue(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 = RotaQueue(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 = 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. + 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 = RotaQueue(file) + repeat(4) { first.addPoint(point(it)) } + first.addQso(qso(1)) + + val second = RotaQueue(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 = RotaQueue(file).apply { load() } + assertThat(q.isEmpty()).isTrue() + } + + @Test + fun `points are capped, dropping the oldest`() { + 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 = RotaQueue(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 = RotaQueue(null) + q.addPoint(point(1)) + 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/rota/RotaSyncStateTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaSyncStateTest.kt new file mode 100644 index 000000000..0eb767df1 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/RotaSyncStateTest.kt @@ -0,0 +1,109 @@ +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 + +/** + * 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 RotaSyncStateTest { + 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/rota/SmartBeaconRouteFidelityTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/SmartBeaconRouteFidelityTest.kt new file mode 100644 index 000000000..8feeeec31 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/SmartBeaconRouteFidelityTest.kt @@ -0,0 +1,163 @@ +package radio.ks3ckc.ft8af.rota + +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/rota/SmartBeaconSamplerTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/SmartBeaconSamplerTest.kt new file mode 100644 index 000000000..aa15c91bf --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/rota/SmartBeaconSamplerTest.kt @@ -0,0 +1,259 @@ +package radio.ks3ckc.ft8af.rota + +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) + } +} 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") + } +} 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") + } +} 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("—") + } +} 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") + } +} 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") + } +} 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) + } +} 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°") + } +} 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..f8f449ba6 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/DecodeCollapseTest.kt @@ -0,0 +1,395 @@ +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 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.DISTANCE) + assertThat(nextSortMode(DecodeSortMode.DISTANCE)).isEqualTo(DecodeSortMode.LAST_HEARD) + } + + @Test + fun showTimeGroupDividers_onlyInLastHeard() { + 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 + 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/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() + } +} 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..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 @@ -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,81 @@ 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. + 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 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" } + 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 ae1ef704c..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 @@ -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,75 @@ 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 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) + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + EmptyState(selectedFilter = "New Grid") + } + + 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() + } } 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) + } +} 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") + } +} 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) + } +} 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) + } +} 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/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("") + } +} 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") + } +} 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) + } + } +} 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") + } +} 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") + } +} 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") + } +} 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/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) 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() + } +} 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() + } +} 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) + } +} 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() + } +} 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/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/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() { 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() {