Conversation
…clean
DebugInject started a demo POTA activation with notes="debug inject", which
renders as a caption on the POTA activation card — fine for the emulator
harness, but it leaks into Play Store screenshots. Pass notes=null instead,
matching a real no-notes activation (PotaScreen calls start(..., notes.ifBlank
{ null })), so the demo card shows just the park/QSO/elapsed with no caption.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…call
Ft8Message.checkIsCQ() dereferenced callsignTo (callsignTo.trim().split(...))
before its guard, and that guard checked the wrong variable: `if (s == null)`
after `String s = callsignTo.trim().split(" ")[0]` is dead, because
String.split()[0] is never null. The value that can actually be null is
callsignTo itself — it defaults to null and stays null for free-text/telemetry
frames and unresolved-hash decodes that still reach the published decode list.
The Compose decode screen calls checkIsCQ() unconditionally on the main thread
with no try/catch — DecodeRow (every rendered row), resolveQsoStatus, and
DecodeScreen.filterMessages — so such a message crashed the whole app on the
primary screen. This is proven reachable by the existing #254 guard in
ActiveQsoPanel (`if (msg.callsignTo != null && msg.checkIsCQ())`), a sibling
consumer of the same mainViewModel.mutableFt8MessageList; that guard was added
to ActiveQsoPanel but not to the decode-list consumers.
Root-cause fix: null-guard callsignTo inside checkIsCQ() itself, the single
choke point through which every caller funnels, so a missing destination is
treated as "not a CQ" instead of throwing. Behaviour is unchanged for every
message that has a callsignTo.
Test: Ft8MessageTest.checkIsCQ_falseWhenCallsignToNull (throws NPE before the
fix, passes after). Full :app:testDebugUnitTest and :app:assembleDebug (all
4 ABIs) verified green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The FT8 sound-card TX branch of playFT8Signal claimed exclusive audio focus and kept PTT keyed, then built and drove a streaming AudioTrack — new AudioTrack()/play()/write() can all throw (bad route/rate, DEAD_OBJECT, an uninitialized track). afterPlayAudio() (which drops PTT, releases the track, and abandons audio focus) was only reached on the straight-line normal exit, so a mid-setup/mid-write throw skipped it. DoTransmitRunnable, the TX worker on doTransmitThreadPool, has no top-level try/catch, so the escaping exception (a) crashed the whole app and (b) left PTT keyed into the RX window, audio focus held (other apps stay ducked until process death), and the AudioTrack leaked — a stuck carrier is never acceptable. The parallel Tune path (playTuneTone) already guards this with try/catch/finally; the FT8 branch #597 added did not. Fix: extract the sound-card body into playViaAudioTrack (sibling of playViaUsbAudio) and run it under a new package-private runPlaybackWithTeardown(body, teardown) that swallow-and-logs a body failure and always runs teardown exactly once — mirroring playTuneTone. Happy path is byte-identical (same single afterPlayAudio() call). RunPlaybackWithTeardownTest added (pure JVM): teardown runs exactly once on normal completion and on a thrown body, and a body exception never propagates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause: PskReporterSender.toSpotRecord gated the reported
senderLocator on `msg.maidenGrid?.takeIf { it.length >= 4 }` — a naive
length check. The JNI decoder stores the end-of-QSO sign-off token
"RR73" into Ft8Message.maidenGrid because it is a syntactic 4-char grid
look-alike (R,R in the A-R field range; 7,3 in the 0-9 square range).
"RR73" is not a location: ft8_lib `packgrid` always packs "RR73" as the
roger-73 report (MAXGRID4 + 3), never as a grid, so no compliant FT8
transmitter ever means grid RR73 (a phantom cell in the Arctic Ocean),
and WSJT-X never treats it as one.
Effect: every QSO-ending RR73 the app decodes was uploaded to the global
PSKReporter spot database as the sender's Maidenhead locator "RR73",
polluting a shared ecosystem resource with phantom Arctic coordinates.
The rest of the app already excludes this token wherever it classifies a
grid — GeneralVariables.checkFun1 (`!extraInfo.equals("RR73")`),
MaidenheadGrid.gridToLatLng (returns null for RR73), and CountDbOpr's
distance stats. The PSKReporter path was the one consumer that missed it.
Fix: extract the decision into a pure, testable `reportableLocator()`
helper that keeps genuine grids but drops the RR73 sign-off (and, as
before, absent/too-short grids). Behaviour is unchanged for every real
locator. No protocol/DSP/threading change.
Testing: added reportableLocator cases to PskReporterSenderTest
(keeps FN31/IO91wm, drops RR73/rr73/null/empty/short); the RR73 case
fails against the old length-only check and passes after the fix. Full
:app:testDebugUnitTest suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Yaesu2RigConstant.setOperationFreq packed the low nibble of the last BCD byte as `freq % 100` — the full 0-99 sub-100 Hz remainder crammed into a single 4-bit nibble. The rig's own decoder (Yaesu2Command.getFrequency) weights that nibble x10 as the tens-of-Hz digit, so the encoder and decoder were not inverses: setOperationFreq(14_074_050) round-tripped to 14_074_320 (+270 Hz), and endings whose remainder exceeded 15 produced a non-BCD nibble (>1 kHz off). On live TX this keys the rig on the wrong VFO for any dial that is not 100 Hz-aligned (10-Hz-resolution reads, custom bands per issue #470), pushing the FT8 signal off frequency. Root cause: the last nibble must be the tens-of-Hz digit `freq % 100 / 10` (the sub-10 Hz digit is below the rig's CAT resolution and is correctly dropped). This makes the encoder an exact inverse of the decoder for any 10 Hz-aligned VFO. PR #497 fixed the matching decoder and explicitly left this encoder quirk untouched; this completes the pair. Pure-JVM tests added/updated in Yaesu2RigConstantTest and Yaesu2CommandTest (tens-of-Hz encoding, full round-trip, sub-10 Hz drop); confirmed red against the old encoder, green with the fix. Full :app:testDebugUnitTest suite passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The FT8 end-of-QSO roger-73 report "RR73" is a 4-char Maidenhead look-alike (R,R are valid A-R field letters; 7,3 valid digits), so the decoder copies it into a message's `grid` field via `looksLikeGrid`. `PskReporter.makeSpot` gated the reported `senderLocator` on a naive `grid.count >= 4`, so every decoded RR73 was spotted to the global PSKReporter database as locator "RR73" — a phantom Arctic coordinate (83.5N, 175E) that pollutes a shared ecosystem resource and breaks WSJT-X interop (no protocol-compliant transmitter ever means grid RR73). The rest of the Kit already excludes it wherever it classifies a grid (`gridToLatLon` returns nil; `QsoEngine` maps it to the `.rr73` stage) — PSKReporter was the one consumer that missed it. This mirrors the Android fix (PskReporterSender.reportableLocator, PR #612), the iOS side of the same ecosystem-interop defect. Fix: extract a pure `reportableLocator(_:)` helper that requires a >= 4 char locator AND rejects the "RR73" sign-off (case-insensitive), and gate `makeSpot`'s locator on it. Well-formed grids are unaffected. Tests: PskReporterTests gains reportableLocator cases (accepts real grids, rejects short tokens and RR73/rr73/Rr73) and a makeSpot case asserting an RR73-grid decode is still spotted but with a nil locator. Verified red->green (reverting the helper to the old length-only check fails 4 cases). Whole-module `swift test` on Linux is blocked by an unrelated Apple-only `import Network` in WsjtxUdpService.swift; the Foundation-only PskReporter source + its tests were run in isolation via SwiftPM. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hamlib loopback bridge (added in feat/hamlib-cat #516) was the one JNI entry point in the codebase that never adopted the null-checked-pin pattern every other feed uses (see ftx_feed.h). nativeFeedFromRig runs on the serial read thread for every CAT reply while a rig is connected. It (1) called GetArrayLength(data) with no null guard on the jarray (a NULL jarray to GetArrayLength is undefined behaviour) and (2) fed the GetByteArrayElements result straight into write() with no null check — a failed pin (out-of-memory / heap pressure) returns NULL with a pending exception, which was then left unhandled on the CAT read thread. nativeSetMode had the same unchecked-pin issue with GetStringUTFChars before rig_parse_mode(). Fix: - Guard data == null; on a failed byte-array pin, ExceptionClear() and drop the frame instead of dereferencing NULL / returning with a pending exception. - Guard mode == null and a failed string pin in nativeSetMode. - Extract the short-write-tolerant forwarding loop into a pure, host-testable header hamlib_feed.h (hamlib_feed_write), a no-op on NULL / len<=0 / fd<0. Behaviour is byte-identical for every valid CAT frame; the only change is that a null pointer or a failed pin is now a guarded no-op. Testing: - New host test test_hamlib_feed.c (pipe + reader thread): valid feed delivered exactly, a 256 KiB frame drains through short writes with no loss, and NULL / zero-length / negative-length / negative-fd inputs are guarded no-ops. Wired into run_host_tests.sh (the CI host); .ps1 gets a skip-note since hamlib_jni.cpp is POSIX-only (sockets/pthread) and never builds on Windows. - Full host C suite passes (CC=zig cc); :app:externalNativeBuildDebug green across all 4 ABIs; :app:testDebugUnitTest green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…it an invalid locator getGridSquare() converted latitude/longitude to a Maidenhead locator by truncating each field/digit index with no upper bound. At the North Pole (lat == 90) the latitude field index reached 18 — one past the legal A-R range — emitting the letter 'S', e.g. the invalid locator "JS09". The same overflow exists on the +180 antimeridian in the raw longitude math (Play Services LatLng normalizes 180 -> -180, so that axis is only reachable via the extracted function, but it is defended too). This grid is written to config as the operator's own grid, transmitted in FT8 messages, and uploaded to PSKReporter, so a pole fix polluted the QSO and the shared spot database with a non-existent locator. Fix: clamp each field/digit/subsquare index to its legal Maidenhead range, folding the boundary onto the northernmost/easternmost cell (field R) — the standard convention. The clamp is a no-op for every in-range coordinate (which never reaches a clamp ceiling), so ordinary fixes are byte-identical. This mirrors the defensive clamps already used in this class (gridToLatLng's +-85 map clamp, greatCircleDistanceKm's acos domain clamp). The lat/lon -> grid math is extracted into a pure static gridSquareFor(lat, lon) so the boundary behavior is unit-testable without a Play-Services LatLng. Adds a Robolectric North-Pole regression test (fails pre-fix) plus a pure-JVM MaidenheadGridSquareTest covering the antimeridian, poles, a global sweep, out-of-range inputs, and byte-identical output for known locations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause ---------- UtcTimer runs two java.util.Timer tasks: a 10 ms cycle-boundary check (secTask) and a 1 s heartbeat (heartBeatTask). Each tick submits its callback to a cached thread pool via execute(). delete() tears the timer down by cancelling both Timers and then calling shutdownNow() on both pools. Timer.cancel() does not wait for a TimerTask that is already running, so a tick can be mid-run() — about to call pool.execute() — at the instant delete() shuts that pool down from another thread. With the default AbortPolicy, execute() then throws RejectedExecutionException. secTask's catch only handles InterruptedException and heartBeatTask has no catch at all, so the exception escapes TimerTask.run(), terminates the Timer thread, and reaches the process's default uncaught-exception handler — crashing the app. delete() runs on every app exit (ComposeMainActivity.onDestroy) and every FT8/FT4/FT2 mode switch (rebuildTimer), while the 1 s heartbeat is always live, so the race is exercised routinely. Fix --- Build both pools with a ThreadPoolExecutor.DiscardPolicy rejected-execution handler (otherwise identical to Executors.newCachedThreadPool()). A submit that loses the race with shutdown is now silently dropped — the correct behaviour during teardown, since the cycle/heartbeat callback is moot once we are shutting down. This is a no-op in normal operation: with an unbounded maximum pool size over a SynchronousQueue, a submit is never rejected until the pool has been shut down. Testing ------- Added pure-JVM tests to UtcTimerTest: the discarding pool tolerates a submit after shutdownNow() (the crashing path), a vanilla cached pool still throws there (documenting the pre-fix bug), and the discarding pool still runs work submitted before shutdown. Full :app:testDebugUnitTest passes (29 UtcTimerTest cases). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ace) The QRZ image clients (QrzWebClient, QrzXmlClient) cached lookups in an access-ordered LinkedHashMap. Reads ran on Dispatchers.IO under a coroutine mutex, but clearCache() — invoked from the Settings screen on the main thread when the user saves QRZ credentials — called cache.clear() with NO lock. An access-ordered LinkedHashMap re-links its internal list on every get(), so an unlocked clear() racing a locked get() is a data race on a non-thread-safe collection: it can corrupt the map and, in the worst case, throw from Map internals. The avatar LaunchedEffect that drives the lookups per decode row has no try/catch, so an escaping throwable would take down the coroutine. Fix: extract a small internal thread-safe LruCache backed by the same access-ordered LinkedHashMap, with every get/put/clear @synchronized on one monitor, and use it from both clients. This also removes the duplicated manual eviction loop (now handled by removeEldestEntry) and drops the now-unused coroutine mutex from the web client. Tests: new pure-JVM LruCacheTest covers get/miss/clear, LRU eviction and access-order protection, a deterministic proof that get() on an access-ordered map is a structural modification (why clear must be synchronized), and a concurrent get/put/clear stress smoke test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The distance-ring overlay on the azimuthal map was scaling each ring by an extra factor of PI/2. Station markers and the land outlines are placed via azProject(), which projects a point at angular distance c to a normalized radius of c/PI — so the disc edge (screen radius r) corresponds to the antipode, PI * 6371 km away. The range rings, however, multiplied (km / maxKm) * r by PI/2, making every ring ~57% larger than the true great-circle distance it claims to represent. Consequences: - The 2500/5000/10000 km rings sat well outside where a station at that distance actually plots, so the operator could not read a marker's range off the rings. - The 15000 and 20000 km rings landed at ~1.5x the disc radius, entirely outside the visible/clipped disc, so they never rendered at all. Root cause: drawRangeRings used a bespoke maxKm=20015 plus a stray PI/2 factor instead of the same normalization azProject applies to markers. Fix: extract the ring radius into a pure, testable rangeRingRadiusPx(km, r, scale) that mirrors the marker projection exactly (normalized radius = km / MAP_EDGE_KM, MAP_EDGE_KM = PI * 6371), and drop the PI/2 factor. Testing: added three pure-JVM cases to MapProjectionTest — edge distance fills the disc, linearity in distance/scale, and (the regression guard) that a ring lands exactly on top of the azProject-projected marker for the same great-circle distance across four operator/target pairs. All three fail against the old PI/2 code and pass after the fix; full testDebugUnitTest is green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bundled reference-data assets were read with `new byte[inputStream.available()]` followed by a single `inputStream.read(bytes)` whose return value was ignored. Neither is reliable: `available()` is only a hint, and one `read(byte[])` is explicitly permitted to return fewer bytes than requested. Android's `AssetInputStream` decompresses on the fly, so a large compressed asset is delivered in chunks and the lone read keeps only the first chunk, leaving the rest of the buffer as NUL bytes. Impact: - cty.dat (~280 KB) — the callsign->country/CQ-zone/ITU-zone map — is silently truncated, so callsigns past the first chunk resolve to the wrong (or no) country/zone in the decode list, on the map, and in ADIF export. - ituzone.json / cqzone.json / dxcc_list.json (450-740 KB) are parsed as JSON right after the read; a truncated buffer makes `new JSONObject(...)` throw, wiping the entire zone/DXCC table. This is the same class of bug the team already fixed for log import in `LogFileImport.readFully`; these were the remaining copies of the pattern. Added `Streams.readAllBytes(InputStream)`, which drains the stream to EOF in a loop, and routed all eight sites through it (CallsignFileOperation, RigNameList, OperationBand, DatabaseOpr x3, HelpDialog, ClearCacheDataDialog). Behavior is otherwise unchanged (same default-charset decoding). Tests: new StreamsTest covers full-drain of a stream that short-reads one byte per call and one that under-reports available(); a new CallsignFileOperationTest case feeds a chunked stream and asserts every record survives (fails against the old single-read, passes now). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR #586 added a per-tick boundary trigger (boundary_tx_ready) that keys a queued reply/CQ early in its slot, independent of decode arrival, so a fast decode no longer drops the reply. Unlike its sibling per-tick actions (the decode trigger and the waterfall row), it was not gated on self.decoding. StopDecode sets decoding=false and resets pending_decodes to 0 (opening the awaiting gate) but leaves qso.active and tx_parity intact. So a CQ/QSO left active when the operator stops decoding kept firing the boundary trigger every eligible slot: the rig transmitted unattended, with the receiver off, and the QSO could never complete (no decodes to advance it). Reachable from the UI — 'Call CQ' has no disabled guard and the decode toggle is independent of TX state. Before #586, maybe_transmit was only reached via handle_decoded, so StopDecode implicitly halted auto-TX; this is a regression. Root-cause fix: gate boundary_tx_ready on decoding, matching the sibling triggers. Byte-identical whenever decoding is on (the normal path); only the decoder-off case changes, restoring the pre-#586 behavior. An in-flight transmission still finalizes (PTT drops) via the existing tx_playback poll. Added boundary_tx_is_dormant_while_decoding_is_stopped and threaded the new decoding arg through the existing boundary_tx_ready test. Verified the pure-function logic under rustc (fails pre-fix, passes after). The full Tauri crate can't link in-sandbox (libdbus-sys build script needs system libdbus); desktop CI compiles and runs these tests via cargo llvm-cov. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Debug: clean 'debug inject' caption off demo POTA card
The always-on web logbook's /IMPORTLOGDATA handler read the multipart
upload as `files.get("file1").hashCode()`. NanoHTTPD's parseBody() only
adds a "file1" entry when the POST/PUT actually carries that file field,
so a malformed or non-form request — or a bare LAN probe of the logbook
port — left the map without the key and the unconditional deref threw a
NullPointerException.
That throw escaped serve() before its try/catch (the IMPORTLOGDATA branch
runs in the leading dispatch chain, ahead of the try at the bottom of
serve), so NanoHTTPD's worker aborted the request with no useful response.
The sibling handlers in this same file were already hardened against
missing path segments (uriSegment, PR #584) and malformed pagination
params (parseQueryInt/clampPageIndex, PR #585); this closes the matching
gap for the upload part.
Fix: extract a bounds-safe `uploadedFilePath(Map)` helper (null when the
part is absent) mirroring the existing static guards, and reject a null
result with the existing html_illegal_command page — the same fallback a
non-POST method already returns — instead of dereferencing it. Well-formed
uploads are byte-identical.
Adds LogHttpServerUploadPartTest (pure-JVM, no Robolectric, mirroring
LogHttpServerUriSegmentTest / LogHttpServerQueryParamTest).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GuoHeQ900Rig.checkHead() counted 0xA5 bytes anywhere in the read buffer and returned as soon as the 4th was seen. But every GuoHe frame begins with FOUR CONSECUTIVE 0xA5 sync bytes (GuoHeRigConstant), and a status frame's payload carries two big-endian VFO frequencies whose bytes are frequently 0xA5. When a serial read splices a prior frame's 0xA5-bearing tail onto the next frame's sync run, the counter reached 4 partway through and returned an index *into* the sync run. onReceiveData then read a 0xA5 as the length byte: (byte)0xA5 + 1 == -90 -> new byte[-90] -> NegativeArraySizeException The exception is swallowed by onReceiveData's try/catch (so it's not an app crash) but it aborts framing before clearBuffer(), leaving stale partial-frame state and silently dropping the frequency update — an intermittent rig frequency-tracking failure on the GuoHe Q900. Fix: reset the run counter on any non-0xA5 byte and return the first byte after a run of >=4 consecutive 0xA5 (the true length byte). This also correctly skips a stray leading 0xA5 that would otherwise make five in a row, and reports "no header yet" (-1) instead of overrunning when a sync run lands at the very end of a read. checkHead is now package-private static (it uses no instance state) so the framing logic is covered directly by GuoHeCheckHeadTest without standing up the rig's Timer/connector. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The TX-500 inherited its meter read+parse from KenwoodTS2000Rig, which is incompatible with the TX-500's CAT interface, so SWR always stayed 0 and MeterProtectionController never tripped: - readMeters() only ever sent RM;. The TX-500 needs RM1; to switch the meter to SWR first, then RM; returns the reading. - The RM reply RM1vvvv puts the SWR selector at index 0 (not index 2 like the TS-590) and the value at substring(1,5), so is590MeterSWR() never matched. - The raw 0000-0030 field needed a TX-500-specific curve to normalize into the 0-255 scale MeterProtectionController's halt threshold uses. Give DiscoveryTX500Rig its own meter handling: send RM1; before RM;, parse the RM1vvvv layout, and map the 0-30 field to an SWR ratio (linear per Lab599's published chart: swr = 1 + cat/3) normalized on the same scale the halt threshold is stored on. ALC is reported as -1 (not present on this rig). KenwoodTS2000Rig gains protected sendMeterReadCommand()/handleMeterReply() hooks so the override is a clean seam rather than a copy of the RX path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The From/To date-range fields in the Export QSOs sheet were plain numeric EditTexts requiring a hand-typed YYYYMMDD value with no calendar UI and no validation. This adds a MaterialDatePicker behind a trailing calendar icon on each field while keeping the field fully editable by keyboard. - Tapping the calendar icon opens a Material date picker; a valid YYYYMMDD already in the field is preselected, otherwise today. - Selecting a date fills the field as YYYYMMDD; typed entry still works and a field can be cleared back to empty (empty = no bound, unchanged). - On Share / Save, a non-empty field that is not a strict YYYYMMDD date is rejected with a ToastMessage instead of running a broken query. - The value handed to ShareLogs remains a YYYYMMDD string (or null), so the query-layer date contract is unchanged. Date parse/validate/format logic is extracted to package-private static helpers on ExportLogSheet and covered by ExportLogSheetDateTest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TX Delay was not applied until the value was changed after startup. The saved delay is loaded into GeneralVariables.transmitDelay and applied to the current UtcTimer in ComposeMainActivity.initData(), but the very next call — applyLoadedOperatingMode() -> FT8TransmitSignal.rebuildTimer() — threw the timer away and built a fresh one that starts with time_sec = 0, silently discarding the delay until the operator re-edited it. The same reset hit runtime FT8/FT4/FT2 mode switches. Carry the outgoing timer's offset onto the rebuilt one inside a new package-visible static helper (rebuildTimerPreservingOffset) so the fix covers both startup and mode switches, and the carry-over is unit-testable via getTime_sec() without constructing a full FT8TransmitSignal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address Copilot review: the trailing calendar icon is a touch-only hit
target on the EditText's compound drawable, so TalkBack users could not
activate the picker. Register a ViewCompat custom accessibility action
("Open calendar date picker") on each field so the picker path is
operable via accessibility services, not just touch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The USB-direct (libusb) TX path in UsbAudioDevice.writeAudio() upsampled the 12 kHz FT8 waveform to the device's 48 kHz rate with naive linear interpolation. A linear interpolator convolves with a triangular kernel (sinc^2 response), which only lightly attenuates the spectral images of the 12 kHz-sampled tone. For a ~1500 Hz FT8 tone those images land at 10.5/13.5 kHz — inside the 48 kHz output band — and ride into the radio's modulator as audible harmonic distortion on TX (Yaesu FT-710 report). The phone-speaker path stays clean because the OS USB driver resamples with a proper band-limited filter; only the app's own direct-libusb path used the crude interpolator. Replace it with TxUpsampler, which reuses the host-tested Blackman-windowed-sinc polyphase kernel already used on the capture side (RationalResampler): exact L/M rational resampling with a stopband well below FT8's ~3 kHz top, group-delay compensated so the leading Costas sync array is not shifted or clipped. Images are now rejected by >40 dB. Adds TxUpsamplerTest covering length, frequency/amplitude preservation, image rejection vs. the old linear path, the 44.1 kHz non-integer ratio, and degenerate-rate guards. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Log.e(TAG, msg, e) instead of string-concatenating the exception, so the full stack survives into logcat for field debugging. - Correct the Javadoc: the helper is not "free of Android types" — it calls android.util.Log, which is a returnDefaultValues stub in unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A write() interrupted by a signal leaves the fd healthy, but the loop broke on any write() <= 0 — so a signal landing on the CAT read thread mid-write silently dropped the rest of the rig's reply and hamlib saw a short frame. Retry on EINTR; every other non-positive return still ends the loop. New host test case 6 fills the pipe to capacity (the only state where the kernel reports -1/EINTR rather than a short write) and interrupts the blocked write with a repeating SIGALRM installed without SA_RESTART. It fails on the pre-fix loop and passes on the fixed one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
It described a 6-character grid from NMEA-format coordinates; the method takes decimal-degree LatLng values and returns a 4-character locator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…down DiscardPolicy drops every rejection, so a rejection during normal operation (e.g. thread creation failing under resource exhaustion) would silently swallow the cycle/heartbeat callback. DiscardOnShutdownPolicy discards only when executor.isShutdown() — the teardown race this fix targets — and delegates everything else to AbortPolicy, keeping real failures visible. New test: a saturated still-running pool with the policy installed still throws RejectedExecutionException. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LogHttpServer (NanoHTTPD on port 7050, bound to all interfaces with no
auth) echoed untrusted query params and user-controlled DB fields into
generated HTML with no or incomplete escaping. The ad-hoc
`.replace("<", "<")` calls escaped only `<`/`>`, so a `"` still broke
out of a `value="…"` attribute — e.g. `?callsign="><script>…` in
showQslCallsigns/getCallsignQTH executed script.
Add a single central HtmlContext.htmlEscape() (escapes & < > " ', &
first, null-safe) and apply it to every request-derived value and
user-controlled DB field before it enters markup:
- showQslCallsigns / getCallsignQTH query-param reflections (attribute)
- getCallsignQTH, getQSLCallsigns callsign/grid/mode/band cells
- follow-callsign list (element + delfollow href, now quoted)
- message / QSOSWLMSG / QSOLogs lists: callTo/callFrom/call/
station_callsign/extra/gridsquare/operator/comment
Removes the partial `.replace("<", …)` escapes in favor of the central
helper. SQL is already parameterized, so this is HTML/XSS only.
Adds HtmlEscapeTest covering element- and attribute-context (double- and
single-quote) breakout payloads, &-first ordering, and null/passthrough.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The join timeouts were ignored and `stop` was only set after joining, so a stalled worker could let the test pass while leaking a live thread. Workers are now daemons, the joins share one 30s deadline, any thread still alive at that point is asserted as a failure, and `stop` is used only as the fallback that asks the loops to bail out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The worked-station handling (Settings → Decode Highlights) lets the operator
pick which stations count as worked — on this band, worked before anywhere,
worked today, or from a list — and what to do with them (highlight / ignore /
hide). The feature request additionally asked for the "…on this band and mode"
variants, i.e. only treating a station as worked when the earlier QSO was on
the same mode you're operating.
This adds that as an orthogonal "Same mode only" toggle. When on,
WorkedModeFilter appends an `upper(mode) = ?` predicate to the worked lists
loaded in DatabaseOpr.GetAllQSLCallsign (current-band, other-band and today),
so a station worked only on a different mode (e.g. FT4 while you're on FT8)
still shows as new. The filtering happens at list-load time, so every scope
that reads those lists honours it automatically; FROM_LIST is user-maintained
and unaffected. WorkedModeFilter is a plain, side-effect-free helper so the
predicate is unit-testable without Robolectric.
The setting is persisted via writeConfig("workedSameMode", …), hydrated in the
config loader, and the worked lists reload immediately when it changes. The
toggle is only offered for the band/before/today scopes.
Tests: WorkedModeFilterTest (pure predicate/arg logic) and
GetAllQSLCallsignModeTest (Robolectric, drives real SQLite to confirm the
refinement filters all three worked lists).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Streams.readAllBytes can throw part-way through a read, and the manual close() after it was skipped on that path, leaking the AssetInputStream. Same fix in both help/clear-cache dialogs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rlay (#693) The map already fetches PSK Reporter reception reports for the operator's own callsign and plots each receiver as a dot, but reading "how far is my signal actually getting out?" off a scattering of dots means panning and zooming. This adds a glanceable bottom card that answers it directly: - how many distinct stations heard us, - the furthest receiver (great-circle from our grid) + its callsign, - the receiver that copied us with the strongest SNR. The card appears only when the overlay is in the "Heard me" direction and no individual station is selected, so it never competes with the existing station-detail / filter sheets. It reuses the existing 5-minute PSK poll — no extra network traffic. The reduction is a pure, unit-tested helper (summarizeSignalReach): de-dupes reports by callsign (keeping the strongest), tolerates a missing operator grid (count + signal still shown, distance omitted), and skips blank callsigns. The composable is a thin renderer over it. Co-authored-by: Optio Agent <optio-agent@noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Add a Worked All Continents (WAC) award to the logbook WAC — a two-way contact with each of the six populated continents — is one of the oldest and most recognisable amateur-radio awards, yet the logbook tracked DXCC, CQ/ITU zones and grids but never continents. This adds real WAC tracking derived the same way the existing DXCC/zone stats are: each logged gridsquare is resolved through the DXCC lookup tables (grid -> DXCC entity -> continent). A new Stats-tab card shows the six continents as chips (worked ones highlighted, "N / 6" progress, a completion banner at 6/6), and the Awards tab gains a real WAC progress bar in place of nothing. - CountDbOpr.queryWorkedContinents(): synchronous grid->continent join, extracted so it is unit-testable against an in-memory DB; wrapped by a new getContinentCount() AsyncTask mirroring getDxcc(). - workedAllContinents(): pure Kotlin reducer turning raw continent codes into award progress (normalises case, dedupes, drops Antarctica/blanks/ junk so they can't inflate the total). - Tests: WorkedAllContinentsTest (pure JVM) and CountDbOprContinentTest (Robolectric + in-memory SQLite) covering the join and filtering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * WAC: dedupe worked-continents query with SELECT DISTINCT + try-with-resources Clearer intent than GROUP BY, and the try-with-resources Cursor is guaranteed to close even if iteration throws. Behavior is unchanged; covered by the existing CountDbOprContinentTest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Optio Agent <optio-agent@noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#701) Three problems found while diagnosing a logbook server that had silently rejected every QSO upload for three days. **Zero-length fields.** An optional value that was present-but-empty (a QSO where the other station never sent a grid) exported as `<gridsquare:0> `. Several ADIF importers treat a length-0 field as malformed and reject the whole record rather than reading it as "absent" — 39 of 384 records in a real export carried one. Empty now means omitted, which every parser agrees on. `mode` had its own null-only guard and so kept emitting `<mode:0>` even after the shared helper was fixed; the new test caught it. `comment` was emitted unconditionally because the `<eor>` terminator was glued onto it — they are separate now. **QSL_MANUAL is not an ADIF field.** The bare name is non-conformant; ADIF reserves `APP_<PROGRAMID>_` for program-specific data. We now write `APP_FT8AF_QSL_MANUAL`, and `QSLRecord` reads both names so files exported by older builds still round-trip with their confirmation flag intact. **Upload failures were invisible.** `uploadAdifToCloudlog` returned a bare boolean and the server's explanation went only to `Log.d`, so `debug.log` recorded `cloudlog=0 qrz=0 of 113` — indistinguishable from having nothing to upload. The reason now flows through `SyncResult` into the log line, which would have read: QsoAutoSync: done (app-start): cloudlog=0 qrz=0 of 113 cloudlogError=HTTP 400: ... column "tx_pwr" of relation "contacts" does not exist That is a 30-second diagnosis instead of a three-day silent backlog. Also collapses `DatabaseOpr.downQSLTable` — the built-in web logbook's ADIF download — onto `AdifRecord`. It was a line-for-line duplicate of `AdifRecord.build()` and had drifted: it still carried both bugs above long after the file-export path was fixed. One builder now, so the next formatting fix can't land in only one of them (-95 lines). Tests: zero-length omission across every optional field, the APP_ prefix and legacy-name import round-trip, and the failure-reason formatting (status + server body, newline collapsing, truncation, and never echoing the submitted ADIF into debug.log). 2881 pass. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ting (#702) * fix: schedule the late full-slot decode so it works in tandem with the early pass With deep decode + early decode both on, the subtract-and-redecode loop ran on the truncated early buffer with an 11.25s budget (after an unbounded first deep pass), so the late full-slot pass didn't start until right as the next slot's early decode began — its first analysis-gate contention then aborted the whole late candidate scan, dropping the high-DT signals the pass exists to recover, almost every cycle. Now the two buffers split the work instead of duplicating it: - The early buffer keeps the time-critical fast pass and the quick first deep pass (pre-key-up sequencer evidence) — unchanged. - The subtraction loop moves to the full-slot buffer (a strict superset of the early one), which becomes the slot's single deep engine and also recovers high-DT signals ~12s earlier than before. - The whole full-slot pass is bounded by an absolute deadline (next slot boundary + early window − 750ms safety, capped by the deep budget), so it finishes before the next slot's early decode starts; the analysis-gate abort becomes a backstop for overruns instead of the routine exit path. No behavior change when no late pass is scheduled (early decode off, or FT4/FT2): the early-buffer subtraction loop runs exactly as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: clarify latePassDeadlineMillis assumes record-time cycle timing Per Copilot review on #702: the deadline is computed from the slot's record-time ModeProfile snapshot, so it only equals the next slot's actual early-decode start while the cycle timing is unchanged. Document that a mid-slot rebuildTimer() (mode switch) moves the real boundary and that the AnalysisGate contention abort is the backstop in that case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…min-SNR floor) (#699) * feat: add Hunt options sheet with target priority and smart filters The HUNT button gets the same notch/long-press affordance as the CQ button, opening a Hunt options sheet: - Hunt priority (single-select): Latest (historical behavior, default), Strongest, Weakest, Farthest, POTA/SOTA activators first (unhunted parks rank highest), New DXCC first, New grid first. - Smart filters: Avoid pileups (prefer CQs no one else is answering this cycle; soft preference) and a Minimum-signal floor (Off/-10/-15/-20 dB; hard filter so Hunt never starts a QSO that's unlikely to complete). Engine: the hunt scan in FT8TransmitSignal previously answered the first qualifying CQ (most recent decode). It now collects all qualifying CQs and ranks them via HuntTargetSelector, a pure Kotlin selector driven by three new persisted settings (huntPriority, huntAvoidPileups, huntMinSnr). LATEST with no filters short-circuits to the old behavior with no extra per-cycle work. All existing eligibility filters (worked-before, POTA-only, directional-CQ respect, exclusions) are unchanged. UI: a non-default priority shows a short tag under the HUNT label (STRONG/WEAK/DX/POTA/DXCC/GRID), mirroring the CQ button's FREE/FD subtitle. Options apply immediately, mid-hunt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: make Hunt tie-breaking explicit instead of relying on sort stability Per Copilot review on #699: append an explicit HuntCandidate.index tie-breaker to every priority comparator and pick with minWithOrNull, so the freshest-decode tie-break is a contract of the comparator (index is unique => total order) rather than an artifact of sortedWith stability, and no ranked copy of the pool is allocated per decode cycle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Swap the TX message mid-cycle when a late decode advances the QSO With early decode on, the fast pass delivers ~13.5s into the slot and the auto-sequencer keys up ~0.45s into the next one. The late full-slot pass (#363) then delivers its recovered decodes 0-3.5s into that next slot — after key-up. When one of those is the partner's reply, the sequencer advances the over a few hundred ms too late and we spend the whole cycle re-sending the message we had already sent. Measured on a real POTA activation (2026-07-30, 126 transmissions): 45 late-pass deliveries landed 0-3.5s into the slot, i.e. after the ~0.45s key-up. In the clearest case the late pass advanced order 3 -> 2 for K5UUT nine milliseconds after key-up; that cycle went out as a repeat of "K5UUT K1AF R-10" instead of the RR73 that would have completed the QSO. Sometimes the race is won instead — W0PPA's arrived 43ms before key-up — so which one you get is decided by milliseconds. The swap is free inside the audio slack. The waveform occupies slotMillis - audioSlackMillis (FT8: 12.64s of a 15s slot), so a restart anywhere within the slack still plays the new message COMPLETE and ends on the boundary; the receiver just sees it at a slightly larger DT, which every FT8 decoder searches anyway. Past the slack the new message could not fit without clipping its leading Costas array, so we let the original over finish and pick the change up next cycle as before. Two properties the implementation depends on: - PTT is NOT dropped for the swap. requestTxRestart() reuses the STOP cancel machinery to stop the writers mid-buffer, but leaves isTransmitting set and never fires onAfterTransmit; afterPlayAudio() takes an early exit that releases only the audio. Dropping and re-raising PTT would add the rig's key-up delay mid-transmission — on some rigs enough to miss the slack window entirely. - The guard reserves RESTART_HEADROOM_MS for the swap itself. The decision is made on the decode thread but playback restarts on the TX worker, so a swap approved at the very edge of the slack would begin playing past it and clip its own leading Costas array — reintroducing the exact defect the feature exists to avoid. The restart check sits outside parseMessageToFunction's body so every path that moves functionOrder is covered (RR73 reply, completion, give-up), not just the ones we remembered to instrument. Free text is excluded: its content doesn't depend on functionOrder, so a swap would replay the identical message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review: PTT strand on STOP, and uninterruptible playback paths Two real defects found in review. 1. afterPlayAudio() took the swap early-exit on txRestartPending alone. A STOP or deactivation landing between the swap being queued and the writers unwinding would therefore skip onAfterTransmit and LEAVE THE RIG KEYED, and leave txRestartPending set so the worker replayed an over the operator had just cancelled. The exit is now conditional on isTransmitting as well; the stop path falls through, clears the flag, and runs the real end-of-over teardown. The replay loop re-checks isTransmitting alongside consumeTxRestart() to close the last window. 2. The NETWORK and CAT-audio branches of playFT8Signal() never observe txAudioCancelled -- they spin on isTransmitting for up to 13.1s/13.0s -- and requestTxRestart() deliberately leaves isTransmitting set. A swap requested on those paths would not interrupt anything: it would sit queued until the wait expired and then replay ~13s into the slot, where the clip math strips nearly the whole message. That is worse than not swapping, so playbackSupportsMidCycleRestart() now refuses up front and the sequencer picks the change up next cycle as before. CAT *control* with sound-card audio stays restartable -- the CAT branch is only taken when the connector reports supportTransmitOverCAT(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…em (#705) * Bound GPS clock discipline to corrections FT8 can survive, and log them GpsClockUpdater.applyFix() writes UtcTimer.delay from every GPS fix, on by default at a 5-minute cadence. That value moves the WHOLE cycle grid -- every decode window and every transmit key-up -- and its only guard was an absolute +/-1 hour bound. Two changes: - MAX_SANE_OFFSET_MS: 1 hour -> 60 s. A phone even a few seconds out cannot work FT8, so an hour-scale "correction" can only be a mock provider, a bogus fix, or a timezone confusion. 60 s still covers a genuinely unsynced clock; past that the operator has a clock to fix. - New step bound (MAX_OFFSET_STEP_MS, 500 ms): reject a fix that jumps the applied offset by more than half a second within a discipline run. The absolute bound cannot catch the failure that actually bites -- a single bad fix whose implied correction looks plausible but slides the grid off the air. Physics makes it cheap to detect: GPS time does not jump and a device clock drifts milliseconds between fixes minutes apart, so a multi-second STEP is bad data whatever its absolute value. Only a run's first fix is unconstrained, since that is the one legitimately correcting accumulated drift. Motivating data (2026-07-30 POTA activation, from debug.log): the app spent two stretches transmitting 5.06 s and 7.63 s off grid. Every over in them keyed up past the 2.36 s audio slack, so lateStartSkipMs clipped the leading Costas sync array out of 17 of 126 transmissions (13.5%) -- loud on the air, undecodable at the far end. Both offsets sit well inside the old absolute bound; only a step check refuses them. That GPS discipline caused those two stretches remains INFERRED, not proven: applyFix only ever logged to logcat, so the pulled debug.log could not show it. Hence the second half of this change -- every applied offset and every rejection now goes to debug.log with the prior offset and the bound that refused it, so the next activation settles it either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review: sample both clocks once in applyFix() SystemClock.elapsedRealtimeNanos() and System.currentTimeMillis() were each read twice -- once for the offset that gets logged, once for the offset that gets evaluated against the bounds -- so the logged "REJECTED fix offset=" was not guaranteed to be the number actually judged. That undermines the diagnostics this PR exists to add, and is most likely to diverge in exactly the situation being diagnosed: a clock being corrected underneath us. Both clocks are now sampled once and the same readings feed the evaluation, the log, and the last-sync timestamp posted to the UI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ond (#706) * Stop setOperationBand re-sending an unchanged dial to the rig ~1x/second From the 2026-07-30 POTA activation: setOperationBand() ran in a continuous ~1 Hz loop for the entire session, re-sending FA014074000; MD0C; NA00;SH0117; about 57 times a minute to a rig that was already on that exact frequency and mode -- rig.getFreq matched the target on every iteration. 20,124 occurrences across the pulled log, present in every POTA session in it, at the same rate during completely healthy stretches. New RetunePolicy makes the retune idempotent: a request is pushed only when it is a new dial, or the rig is not where we want it, or a 30 s reassert heartbeat is due. Ordering matters and is what the tests pin -- correctness beats the rate limit, so a genuine retune is never delayed and the operator can never be left transmitting on the old dial. Only a request redundant in BOTH senses (same target as the last push AND the rig already reporting it) is throttled. This is CONTAINMENT, not a root-cause fix, and the caller driving the loop is still unidentified. It is provably not the connect path (11 autoConnect attempts in the whole window, no connect/disconnect churn logged), not a band change (no bandSelect: lines), and not self-triggering via onFreqChanged (BaseRig.setFreq early-returns on an unchanged dial, and the dial never changed). Two independent ~1.05 s series interleave ~0.53 s apart, one always observing the rig connected and one always observing it disconnected -- which points at duplicated observers or two live view-model instances rather than one runaway timer, but that is inference. So the change also adds a rate-limited suppression log that names the caller via its stack frame: setOperationBand: suppressed 28 redundant retunes (freq=14074000 already set) caller=com.k1af.ft8af.Xyz.tick:42 The stack is only walked on the rate-limited log path, never per suppressed call. The next activation's debug.log should name the culprit so the real fix can follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review: reset the rate limit on connect, harden the clocks 1. The rate-limit state persisted across a reconnect. onConnected() posts setOperationBand() precisely because a reconnect "previously left the rig on whatever frequency it powered up on" -- but that push has the same dial as the last one and a cached baseRig.getFreq() that still matches, so a reconnect inside the 30s reassert window would have been suppressed and silently regressed the bug that retune was added to fix. onConnected() now calls resetRetuneRateLimit() so the push is treated as a first push. Deliberately NOT reset from setOperationBand()'s not-connected branch: in the ~1 Hz loop this rate limit exists to contain, half the calls observe the rig disconnected, so resetting there would re-arm the loop every other iteration and defeat the fix entirely. 2. shouldLogSuppression() relied on a 0 sentinel being far enough below an epoch nowMs to clear the interval by arithmetic, which would have delayed the first line if it were ever fed a monotonic clock. There is now an explicit NEVER_LOGGED sentinel. Both intervals are measured with System.currentTimeMillis(), which is not monotonic. A backwards OS time correction made the raw delta negative and wedged the caller -- retunes suppressed, or the suppression log silenced, until wall time caught up. elapsedSince() saturates on backwards time so both fail safe (one extra CAT write, one extra log line) instead of silently disabling themselves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…osition (#707) * Keep the QSO panel's RX history instead of re-deriving it each recomposition Reported: "sometimes the little QSO details pane would lose the rx messages I had received and it would resize smaller." The panel OWNED its TX rows -- synthTxLog, a remembered state list -- but DERIVED its RX/BUSY rows by filtering the shared decode list on every recomposition. That list is not stable storage: - trimToMessageCount() drops from the FRONT once it reaches MESSAGE_COUNT (3000). At the ~50 kept decodes/min seen on the 2026-07-30 activation that fills in about an hour, mid-session, with no user action. - the clear-decodes-every-cycle setting wipes it at each slot boundary. - clearDecodesAndTarget() empties it on a band change, and the Clear button empties it outright. Any of those retroactively erased conversation the operator had already read, while the TX rows stayed -- exactly the reported asymmetry. And MessageLog's LazyColumn is heightIn(max = 160.dp) with no minimum, so it sizes to its content: fewer rows literally shrank the box, and zero rows swapped in a fixed 40.dp placeholder. That is the resize. RX rows now accumulate per target into their own remembered list, folded forward by mergeRxLog() and reset on a genuine target change alongside synthTxLog. Duplicates are expected input rather than an error: the decode list is cumulative and the late full-slot pass re-delivers a slot's messages, so identity is (direction, utcTime, messageText) -- time included so a station repeating itself in a later cycle still gets its own row, matching how TX rows are logged per transmission. Growth is bounded at MAX_RX_LOG_ENTRIES newest. Also fixes the second path to the same symptom. buildQsoLog returns an empty list when displayCallsign is empty, and that value comes from a LiveData observeAsState the code already documents as briefly emitting null on tab switches (the #250 comment). #250 fixed this for the TX rows with a 500ms settle on synthTxTarget but left the conversation keyed on the raw value. The log now targets displayCallsign ?: synthTxTarget, so a flicker can no longer blank it; a real QSO end still clears both, one target change later, exactly as before. mergeRxLog returns the caller's own instance when it adds nothing, and the composable skips the snapshot write on referential equality -- so a cumulative snapshot that contributes no new rows costs no recomposition. Size alone would have been wrong: at the cap a merge can append and trim in one step, leaving the size identical and the content different. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review: let a known row's metadata be updated, not discarded mergeRxLog keyed on (direction, utcTime, messageText) and kept the FIRST instance of a key, silently dropping metadata carried by later duplicates. That is reachable, and by a sharper route than "the same message might arrive twice": FT8SignalListener.checkMessageSame mutates the STORED Ft8Message in place -- "prefer known SNR over unknown; when both are known, keep the higher" -- and then drops the duplicate. So ft8Messages holds one instance whose snr field improves over time, typically when the late full-slot pass re-decodes a message the fast pass only heard weakly. Keying snr out of identity meant the panel pinned whatever SNR it saw first, often none, for the rest of the QSO. Before this PR the panel re-derived from the live list each recomposition and picked the improvement up immediately, so this was a regression introduced by the accumulation. A repeat of a known key now replaces the stored row when it differs structurally, which mirrors upstream's own resolution (it only ever moves toward the better value). An identical repeat -- the common case, every cycle -- still short-circuits and returns the caller's own instance, so the no-recomposition contract is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ings worse) (#708) * Remove the GPS step bound and debounce the retune reset (both made things worse) Both regressions caught on the 2026-07-31 activation by the diagnostics #705 and #706 added, which is the one thing that went right. 1. Remove MAX_OFFSET_STEP_MS (#705). The reasoning -- GPS time does not jump, so a multi-second step is bad data -- is sound in isolation and wrong as a rule, because it cannot tell "this fix is bad" from "the baseline is bad". Paired with an unconstrained first fix it made the first fix of a run permanent: 16:11:31 applied offset -1331ms (was -5000ms, prior GPS=none) 16:23:53 REJECTED offset -2112ms (prior=-1331ms, maxStep=500ms) ... eleven consecutive rejections over an hour, all near -2200ms ... A cold fix 2.6s after startup set -1331ms and every later fix was refused for being ~870ms away from it. Cost, measured across the 17:23 boundary where GPS finally got through: decodes 5.9/cycle before against 8.7 after, and transmit timing scattered (14 of 110 overs keying up 5s+ into the slot, versus 21 of 23 tight afterwards). One outlier is noise; eleven agreeing fixes are the truth, and the rule could never act on that. Rejecting a correction is not the safe default it looks like -- a stale offset puts the grid off the air just as surely as a bad fix, and unlike a bad fix it never self-corrects. The absolute bound and the logging stay. 2. Debounce the retune rate-limit reset (#706). The suppression log named the runaway caller directly: caller=com.k1af.ft8af.MainViewModel$1$$ExternalSyntheticLambda0.run:0 which is the MainViewModel.this::setOperationBand posted from onConnected(). CableSerialPort fires that on every successful port open(), and the port was re-opening about once a second -- so the reset added in code review re-armed the limiter on every iteration of the loop it exists to contain. Every suppression line read "suppressed 1" and retunes rose to 73/min, above the 57/min measured before the rate limit existed. Resetting is still right for a genuinely new link, so it is now debounced: a burst of connects seconds apart is one flapping link and does not re-arm; a reconnect after a real outage does. This also corrects the record on #706: I ruled out the connect path on 7/30 because there were few autoConnect attempts, but onConnected() fires per port open() without a fresh autoConnect, so that proxy was meaningless. The port re-open storm is the real root cause and is still to be fixed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Stop the CAT reconnect storm: a port that opens is not a link that works Root cause of the ~1 Hz retune loop, the constant "serial.send: port not open!", and the "PTT: unkey still owed after link loss" retries. Two compounding defects in CableConnector: 1. handleSerialError() passed a hardcoded 0 as attemptsSoFar to CatReconnectPolicy.decide(). Since shouldAutoReconnect was `attemptsSoFar < MAX`, a transient error ALWAYS returned RECONNECT and the SURFACE branch was unreachable for anything non-fatal. The parameter exists to bound this and was never fed. 2. startAutoReconnect() treated cableSerialPort.connect() returning true -- the port merely OPENING -- as success, returned, and ended the burst. With a link that opened and immediately errored again, the next error started a FRESH burst at attempt 1, so the escalation (500ms/1s/2s/4s/8s) never got past its first step. Measured on the 2026-07-31 activation: 13,190 port opens in 88 minutes (2.5/s), inter-arrival pinned at 0.51-0.53s -- exactly BASE_BACKOFF_MS plus the open -- and zero "Lost connection" lines. The give-up path never fired once in 88 minutes of continuous reconnecting, which is what proves the budget was being reset by every open. The fix is that only elapsed time proves a link works. The burst counter now persists across opens and resets only after a connection has held for STABLE_CONNECTION_MS, so a flapping link walks up to the 8s ceiling in a few attempts instead of sitting at 500ms forever: ~20x less churn (2.5/s -> 0.125/s). Retry is now unbounded for transient errors, per operator preference. Giving up would strand them with no CAT until they noticed the retry chip, whereas the storm was at least landing commands intermittently. FATAL classifications (device gone, permission denied) still surface immediately -- those don't recover by retrying. This does NOT explain why the port dies right after opening. That is a genuine link fault -- cable, OTG adapter, RFI, driver -- and this change only stops the software turning it into a 2 Hz storm. Expect it to make the underlying instability more visible, not less. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review: atomic burst counter, and rename the stale constant 1. reconnectAttempt was a volatile int mutated with `++` on the CAT-Auto-Reconnect thread while handleSerialError() (serial IO thread) and connect() (UI thread) reset it. `++` is a read-modify-write and is not atomic under volatile, so a lost update would hold the counter down -- pinning the backoff near BASE_BACKOFF_MS and reviving the exact storm this PR exists to stop. Now an AtomicInteger, and handleSerialError() resets-and-snapshots as one logical step so the value handed to decide() is the one that call established. 2. MAX_AUTO_RECONNECT_ATTEMPTS no longer bounded anything -- transient errors retry indefinitely -- so the name was actively misleading. Renamed to BACKOFF_ESCALATION_ATTEMPTS, which is what it describes: the attempt at which backoff reaches MAX_BACKOFF_MS. A new test pins that relationship so the name cannot drift from the behaviour again. The rename surfaced three more docs carrying the same dead "budget" framing -- the class javadoc, the Action.RECONNECT/SURFACE constants, and decide()'s contract -- all corrected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Stop discarding fast-pass decodes that land after key-up
The app was not seeing half the stations calling it. Measured on the
2026-07-31 activation: of 66 cycles where someone addressed us
(replyToMe=true in debug.log), 34 -- 52% -- never reached the auto
sequencer at all. They decoded correctly and were thrown away, so the
operator kept calling CQ at people who were answering and had to pick
callers by hand.
MainViewModel.afterDecode gated the fast-pass parse on three conditions
and silently dropped the decode when any failed:
if (!isTransmitting && !isDeep && replyCost <= budget) {
parseMessageToFunction(messages);
}
Two of those drop live evidence. The decisive one is isTransmitting: a
fast decode is delivered about earlyDecodeMillis plus decode time into
the slot, so a ~2s decode lands a few hundred ms PAST the boundary --
and key-up happens within the first half second. Measured gap between
key-up and the following delivery: 55 deliveries landed 0-0.4s AFTER it.
From the log, four consecutive cycles of exactly this:
16:39:01.841 QSO: TX msg=[CQ POTA K1AF EM28]
16:39:02.101 DECODE: kept=14 replyToMe=true <- 0.26s late, dropped
16:39:31.842 QSO: TX msg=[CQ POTA K1AF EM28]
16:40:01.835 QSO: TX msg=[CQ POTA K1AF EM28]
16:40:31.840 QSO: TX msg=[CQ POTA K1AF EM28]
16:41:01.841 QSO: TX msg=[W3HH K1AF -16] <- only after the
operator stepped in
"enqueue caller" fired twice in the whole session against 66 cycles of
people calling.
Deep passes landing in that same window were ALREADY stashed and
replayed; the fast pass -- the one carrying the timely reply -- had no
such path. It does now, via the existing PendingSequencerDecodes (which
already ages and evicts). Replay runs through the evidence-only parse,
which still answers a station calling us: checkCQMeOrFollowCQMessage is
invoked ABOVE the evidenceOnly guard in parseMessageToFunctionInner.
Only absence-of-evidence decisions stay suppressed, correctly -- this
cycle's no-reply call was already made.
The over-budget branch is stashed too, for the same reason: too slow to
key up this cycle does not make the evidence worthless next cycle.
Both drops were also invisible in debug.log, which is why this survived
four PRs of chasing adjacent problems. Both now log.
Decision extracted to FastPassDisposition so it is unit-tested; its
contract is that there is no third outcome -- every delivery either
parses now or is stashed for replay, and nothing is dropped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Address Copilot review: sample TX state once, and de-duplicate the rationale
1. isTransmitting() was read twice -- once for the decision, once for the
branch that words the log line -- so a flip between them would have
produced a stash logged with the wrong reason. Given this whole class
of bug survived four PRs precisely because the drop was invisible in
debug.log, a diagnostic that can lie about why is not a small thing.
Sampled once into a local now, and the two stash branches collapsed
into one with the message selected from that sample.
2. The activation-specific narrative in the branch duplicated the
FastPassDisposition javadoc. Trimmed to a durable one-liner with the
detail left in the class.
The one non-obvious fact the inline comment carried is now stated in
FastPassDisposition instead of being lost: replay only answers callers
because parseMessageToFunctionInner calls checkCQMeOrFollowCQMessage
ABOVE its evidenceOnly guard, so moving that call below the guard would
silently disable this mechanism.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#711) * Stop commanding the rig a frequency it echoed back during a CAT desync Reported as "it takes a loooong time for the radio to change frequencies when I switch bands or modes". The app is not slow -- it dispatches the retune in 815ms. The delay is a fight afterwards: 19:54:02 bandSelect: band=10136000 <- operator taps 30m 19:54:03 serial.send: FA010136000; <- out in 815ms rig replies "?;" <- rejected 19:54:11 setting freq=10136000 (rig.getFreq=14239985) <- rig reports a value nobody asked for 19:54:29 setting freq=14239985 <- THE APP COMMANDS IT BACK 19:55:01 setting freq=10136000 (rig.getFreq=10136000) <- settles, ~59s, 4 taps 29 rig rejections that session, every one following an FA set-frequency. onFreqChanged wrote whatever the rig reported into GeneralVariables.band, which is also the value setOperationBand pushes out -- so an observation was promoted to a command, and the 30s reassert heartbeat then fought the operator's selection for as long as the bad reading survived. Split the two roles. GeneralVariables.commandedBandHz is the dial the app asserts, set by explicit choices (band picker, mode retune, config load) and by a rig report only while the CAT stream is healthy. band stays the observed value for display, logging and PSK. The trust rule is deliberately narrow, because a report we did not ask for is in general indistinguishable from the operator turning the VFO -- and fighting a manual tune would be its own bug. The one case identifiable from evidence is a report arriving while the rig is refusing our commands, so Yaesu39Rig sets a flag on an unparseable frame and CableSerialPort clears it on the next send. Known limit: this only covers desyncs that produce an unparseable frame. A rig that silently reports a wrong frequency with a well-formed FA reply is still adopted, and would still be commanded back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address review: time-window distrust, live dial re-read Copilot review, both valid: 1. The delayed Runnable used the dialHz captured 800ms earlier, where it previously read GeneralVariables.band at execution time. A band change inside that window would therefore send the OLD frequency first and briefly retune the rig away from the newest selection -- a spurious extra FA on a rig already rejecting them. It now re-reads the commanded dial at execution time. 2. rigRejectedSinceCommand was cleared BEFORE the write was attempted, so a throwing write cleared it without the re-syncing command ever going out. Self-review found a bigger hole in the same mechanism, which subsumes (2): the flag was cleared by ANY outgoing command, and the CAT liveness watchdog polls the rig every CAT_LIVENESS_TICK_MS (3s) with a frequency read. That unrelated poll could clear the flag between the rejection and the bad report, defeating the guard entirely -- and it depended on send ordering with a component that knows nothing about it. Replaced with a timestamp: GeneralVariables.rigRejectedAtMs, and reports within RigDialTarget.DESYNC_DISTRUST_MS of a rejection are not adopted as the commanded dial. Depends on nothing but the clock, so there is no clear-path to get wrong and the CableSerialPort change is dropped entirely. A backwards clock correction does not re-trust. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Hold key-up while this slot's fast decode is still running
Missing callers turned out to be load-dependent, which is backwards from
what an operator needs: a pileup is exactly when auto-answer matters
most. Measured across one morning's activation:
busy 09:15-09:45 quiet 09:45 on
decodes kept per cycle 14.2 6.3
deliveries landing after key-up 35 0
The fast pass is delivered ~earlyDecodeMillis + decode time into the
slot, and key-up fires ~0.45s into the next one, so the decode has under
two seconds. On a busy band it does not make it, delivery slips past the
boundary, and the sequencer has already committed. #709 stopped those
being discarded, but a stashed decode is replayed on the NEXT cycle -- so
a third of callers were still answered 15s late, which is what the
operator was working around by hand.
There is ~1.9s of unused headroom before the audio slack runs out. Spend
it, but only when there is something to wait for: FastDecodeGate marks a
fast pass in flight, and the cycle-timer callback waits for it before
keying up. On a quiet band the decode has already finished and the wait
returns immediately, so key-up timing is unchanged for most cycles.
A fixed delay would have been the wrong shape -- it would tax every
transmission to fix a load-dependent problem. That is why the earlier
"hold key-up ~1.2s" option was rejected in favour of #704's mid-cycle
restart; conditional on a decode actually running, the objection does not
apply.
The bound is the load-bearing part. keyUpHoldLimitMs reserves the
configured pttDelay plus KEYUP_HOLD_RESERVE_MS for waveform generation
and output setup, so a held start still begins inside the slack and clips
no leading Costas array -- the defect this codebase has already shipped
twice. When the reserves exceed the slack the limit floors at zero and
behaviour is exactly as today.
Two ordering details: the gate is marked in flight BEFORE the decode
thread starts (the transmitter can reach its check first and would
otherwise see an idle gate), and released after DELIVERY rather than
after decoding, in a finally, since the sequencer acts inside the
delivery callback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Self-review: stop a failed decode stranding the key-up gate forever
fastDecodeGate.begin() runs before the decode thread starts, but end()
was only in a finally around the delivery call, roughly thirty lines into
run(). Anything throwing before it -- JNI decoder init, pressFloatDecode,
runDecode, OOM -- left the gate in flight permanently, and every later
key-up would then wait out the full hold before transmitting. The bound
means it could not clip audio, but it would silently add ~1.7s to every
transmission for the rest of the session.
The thread body is now wrapped so end() always runs. The inner release
stays: it fires right after DELIVERY so the transmitter is unblocked as
early as possible rather than waiting for the deep passes, and end() is
idempotent (already covered by a test).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Address Copilot review on the key-up hold gate (PR #712)
begin()'s Javadoc claimed the decode thread calls it, but it is called by
the spawning thread on purpose -- marking in flight only once the decode
thread is scheduled would let the transmitter see an idle gate and key up
against a decode about to run. Documented the ownership so the next
change does not "fix" it by moving the call inside the thread.
end()'s doc now also mentions the finally backstop added in c8bc1cd and
that the two release paths rely on it being idempotent.
Docs plus one stray indent from c8bc1cd; no behavior change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…710) * Add RTOTA trip mode: live GPS route + QSOs to rtota.app Roving operators can now record a road trip from the app and have it appear live on rtota.app instead of waiting for an end-of-trip ADIF upload. Settings -> Road Trips (RTOTA) registers the callsign (or takes a pasted API key), starts and ends a trip, shows what has reached the server, and announces a planned trip to followers. While a trip runs, a location-typed foreground service owns the GPS subscription so breadcrumbs keep coming with the screen off, and every QSO written to the log is queued for the same trip (bulk ADIF imports excluded -- the hook rides the existing appendToAdifFile gate). Built for the failure mode roving actually has: no coverage. * Everything recorded lands in an on-disk queue before any network attempt, so a canyon, a reboot, or an OS kill costs time and nothing else. * A trip can be started AND ended with no signal at all; creation and completion are deferred flags the flush loop resolves later. * Flushes are single-flight, batched, backed off to 15 minutes, and triggered immediately when a validated network appears. * TripPointSampler cuts a 1 Hz fix stream down to the shape of the route (time floor, distance floor, plus a point through a turn) and stays silent while parked, which is what lets the server derive overnight stops. The API key lives in Keystore-backed EncryptedSharedPreferences rather than the config table, which the settings-backup export copies verbatim. 58 unit tests cover the sampler, the queue (including a killed-mid-write file), the wire payloads against the server's zod schemas, QSO mapping, and the client's request shape + retry classification via MockWebServer. * Sample the route with SmartBeaconing and pin QSOs to the path Replaces the fixed interval/distance sampler with SmartBeaconing (TM) - the HamHUD scheme APRSdroid, the Kenwood D710 and most APRS trackers use - so the recorded breadcrumbs actually draw the road. Rate follows speed: below the slow threshold beacon rarely, above the fast one beacon at the fast rate, and in between fastRate x fastSpeed / speed, which holds the spacing of points roughly constant instead of the time. Corner pegging sends a point as soon as the course changes by more than minTurnAngle + turnSlope/speed. That division is the whole trick: at 65 mph the threshold is ~19 degrees (an interstate curve), at 25 mph it is ~25, and at walking pace it is effectively unreachable. Measured on a replayed 8.5-mile drive with a 30 s sweeping curve (SmartBeaconRouteFidelityTest): worst deviation of the real path from the drawn polyline is 29 m with corner pegging and 120 m with interval-only sampling, a curve cut across four times wider than the highway itself. Two departures from stock SmartBeaconing, both documented in the profile: * A corner also requires real movement (turnMinDistanceM). APRS trackers read course from GPS velocity, which is undefined when stopped; Android keeps reporting a bearing, so a phone idling at a light would otherwise grow a scribble of points where the truck never moved. A test drives exactly that. * A true standstill emits nothing at all. APRS keeps beaconing to stay visible; RTOTA derives overnight stops from 4h+ gaps, so silence while parked is the signal. Departure is beaconed immediately (RESUME) so the stop is bounded. QSOs now plot on the line rather than beside it. Contacts are stamped from the freshest fix instead of the last beacon (which on an interstate can be half a mile back), and that position is forced into the route as a QSO-anchored vertex, deduped when a point is already within 20 s and 25 m. Ending a trip anchors the final position the same way, so the line stops where the rover did. Profiles (Car / Bicycle / Walking) replace the raw interval and distance rows; the screen prints what the chosen profile will actually do, and the trip card and notification now show which rule kept the last point (corner, contact, parked) so the behaviour is legible from the passenger seat. 75 RTOTA tests: rate curve, turn threshold, speed fallback, corner pegging and its guards, parked silence over an 8 h stop, resume, QSO anchoring, plus the route-fidelity replay above. * Drop the bicycle and walking beacon profiles RTOTA is a road-trip service: the rover is in a vehicle. The other two profiles were speculative, and a picker with one sensible answer is a setting the user has to think about for nothing. SmartBeaconProfile keeps its parameters (they are still worth naming and documenting in one place, and the fidelity test builds variants with copy() to isolate a single rule) but loses the key/ALL/byKey machinery, the stored preference, the mid-trip setter, and the tap-to-cycle row. The tracking section is now a read-only line stating what the sampler does -- still worth saying, since a trail that goes quiet at a fuel stop otherwise reads as broken. 72 RTOTA tests still pass; the route-fidelity numbers are unchanged. * Make trip mode transmittable, deliverable, and locatable Five things stood between RTOTA trip mode and a real drive. **The base URL could never have worked.** DEFAULT_BASE_URL was the apex https://rtota.app, which 308-redirects to www. No HTTP client may follow a 308 for a POST (RFC 9110: the method and body have to survive, so clients decline rather than guess), and every write here is a POST — so trip creation failed permanently, non-retryably, with the whole queue stranded behind it. normalizeRtotaBaseUrl repairs the host on read as well as write, so an install that already persisted the apex heals on upgrade instead of 308-ing forever. **"CQ RTOTA" is not encodable, and was not wired at all.** Nothing in the package ever touched GeneralVariables.toModifier. And the token itself has no encoding: an FT8 standard message packs the CQ into the 28-bit c28 field, whose vocabulary is CQ plus one to four letters. POTA fits at exactly four; RTOTA is five, and Ft8Message drops an over-long modifier silently — you would transmit a bare CQ all day and only find out afterwards. RtotaCqSession imposes RTOA for the duration of a trip and hands the modifier back on the way out. It composes with POTA's own save/restore: a park activation started mid-trip banks RTOA and returns it when it ends, and ending the trip while POTA holds the modifier leaves it alone rather than clobbering an active activation. **Resumed trips re-sent everything.** The service grew a sync-state handshake; a trip resumed from disk now asks what the server already holds and prunes acknowledged contacts by exact dedupe key, turning "re-send the whole day" into "send the last few minutes". Matched on the service's exact key format, because a near-miss merely re-sends a QSO that dedupes anyway while a false match would discard one that never arrived. **Breadcrumbs never carried a highway.** onLocationFix set state but never highway, so the service's highwaysTraveled roll-up was always empty. HighwayResolver names the road from a cache with a refresh policy — it never blocks the location callback, throttles on time *and* distance, and expires a label so it can't be carried across a dead zone. An unresolved fix stays null: "not a highway" is a fact, "we don't know" is not, and conflating them would report local roads across a canyon. **QSOs had no position outside trip mode.** Every real-time contact is now stamped with the operator's coordinates, trip or no trip, and the ADIF carries them as standard MY_LAT/MY_LON plus exact decimal APP_RTOTA_ twins. Only real observations qualify — there is deliberately no tier derived from the configured grid, whose centre would dress a ~55 km square up as a measurement. No fix means no coordinates, and rtota.app places the contact from the breadcrumb trail instead. Verified against the live service: register, create, live POST, an identical re-send that deduped to zero, sync-state, complete. The service's own zod schemas and dedupeKey() accept the app's payloads, and its ADIF parser puts the rover back at the exact coordinate written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review on trip-mode permissions and locale (PR #710) **Approximate location no longer blocks Start trip.** The screen gated on ACCESS_FINE_LOCATION while RtotaLocationTracker runs on fine *or* coarse, so answering "Approximate" to the Android 12+ dialog — an ordinary choice — read as a refusal and re-prompted for a permission already granted. There were three copies of this check drifting apart (screen, tracker, RoverPosition); they now share one definition, so the class of bug is gone rather than the instance. **The notification can now say "parked".** The parked transition happens on the not-a-beacon path, which updated state without publishing — and once parked no fix is kept, so recordPoint's publish() never ran either. The notification claimed the rover was still rolling for as long as it sat there. Republished on the transition only: updateNotification does no throttling of its own, and non-beacon fixes arrive about once a second, so publishing every one would rebuild the notification all trip. **Callsigns upper-case in Locale.US.** The bare uppercase() is locale-sensitive for ASCII: under Turkish or Azeri, "i" becomes "İ" (U+0130), so a phone in that locale would store and register a callsign the server can never match — and disagree with RtotaClient, which already normalized with Locale.US. Swept the rest of the package; this was the only bare conversion left. **Fixed a misleading test doc.** syntheticDrive's KDoc called the middle segment a right-hand curve; the heading runs 90° to 0°, which is a left turn, as its own inline comment said. Regression tests for the two testable fixes: the permission predicate under each grant combination (Robolectric), and callsign normalization under Turkish, Azeri, German and US locales. The notification fix is a side effect on a foreground service and isn't reachable without a service harness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Show DT on every decode, as WSJT-X does The slot bar has carried a clock-sync pill for a while, but it shows the *mean* DT across the cycle — and a mean cannot answer the question an operator actually has when it reads badly. "Every station I hear is at -1.3" means my clock is wrong; "one station is at -1.3" means his is. Those need the same fix in opposite places, and until now the app gave no way to tell them apart. The value was already there and already trusted: `Ft8Message.time_sec` is the per-decode offset, and `mutableTimerOffset.postValue(time_sec)` is what feeds the existing pill and the correction suggestion in Time Sync. This just puts it on the row it belongs to. Rendered WSJT-X style — signed, one decimal, no unit — and prefixed "DT" rather than suffixed with seconds, because the metadata row already ends in an "ago" time and a bare "-1.3 s" beside it reads as another duration. Values that round to zero render unsigned: "-0.0" is noise in a column being scanned for a sign. Amber past ±1.0 s, which is deliberately the same threshold the slot bar's pill calls the edge of "fair" — a row must not shout while the averaged indicator above it is still calm. Verified by unit test and installed on a device; the label itself is not visually confirmed, since showing it needs live signals and no radio was attached. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review on DT visibility and threshold (PR #713) Both valid. **Narrowed to internal.** `formatDecodeDt` and `isDecodeDtNotable` have no callers outside the decode UI and its tests, and `internal` is what the neighbouring ClockSync helpers already use. Tests live in the same module, so nothing needed relaxing to keep them compiling. **Stopped repeating the threshold.** `isDecodeDtNotable` hard-coded 1.0f while its own KDoc claimed it matched `CLOCK_SYNC_FAIR_SEC` — true only by coincidence, and silently false the moment either one moved. It now shares the constant with the slot bar's pill, which is the property that actually matters: a row must never call a reading alarming while the averaged indicator above it still calls it fair. The test asserted the same literal, so it would have sailed straight through that drift. It now asserts against the constant and its neighbours instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Name a trip from the plans already saved on rtota.app Tapping "Trip name" opened an empty text box, so a trip planned on the site had to be re-typed from memory — and a name that doesn't match is a trip nobody can line up with the plan that announced it. The list is of *scheduled activations*, which is what the site's plan wizard actually writes: a trip only exists once someone drives it. Read from /api/me rather than the public /api/activations, because the public listing carries only what a stranger may see, and a plan marked private or followers — the ones most likely to be a real upcoming trip — would be missing from exactly the list the operator is trying to pick from. Picking binds nothing. The server decides which plan a trip fulfils by comparing start times with twelve hours of slack either side, so the value here is that the name matches and that the operator can see, before setting off, whether starting now will inherit the privacy they chose in the wizard. Plans outside that window are still listed — driving early is normal — but say so, because the consequence is otherwise silent: a plan marked `delayed` whose privacy doesn't apply publishes a live position that was meant to lag, and nothing on screen would have mentioned it. The window check mirrors MATCH_SLACK_HOURS in the service's lib/activation-match.ts, including its assumption that an open-ended plan spans a day. It is advisory only; the server remains the decider. With no API key the row goes straight to the free-text box as before — the plans live behind that key, and an empty picker with an auth error in it explains nothing. Verified against the live service: all three of the account's plans listed, the in-window one marked, and picking it set the trip name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review on the plan picker (PR #714) All three valid. **A failed plan fetch no longer reports itself as the trip's problem.** The picker reused `rtota_error`, whose text is "Last error: …" — phrasing that belongs to the trip's own upload failures. An operator whose plans failed to load would have read it as the running trip being in trouble. Now has its own string that says what actually happened. **The plan list scrolls.** It was a bare forEach in a Dialog's Column, so a rover with a season of plans would have rows running off the bottom of the screen with no way to reach them — and on a picker, unreachable means unselectable. Height-capped and scrollable rather than a LazyColumn, so a short list still hugs its content instead of always claiming the cap. **The slack test now pins the boundary.** It was named for a twelve-hour rule while asserting eleven hours in and thirteen out, which would have passed just as happily against an eleven- or thirteen-hour rule — the constant was never actually tested. Now asserts the inclusive edge to the millisecond either side, plus the constant itself. The open-ended-plan test had the same weakness (37 h, where the rule is 36) and got the same treatment, though Copilot only flagged the first. Unit tests pass. The picker was verified on-device before these changes; the layout restructure is NOT visually re-confirmed, because the phone locked and needs biometric auth I can't supply. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…crash) (#715) * Bump the schema version so my_lat/my_lon actually get added (fixes a crash) Every logged QSO crashed the app for anyone who upgraded rather than installed clean: SQLiteException: table QSLTable has no column named my_lat at DatabaseOpr.doInsertQSLData(DatabaseOpr.java:1565) The position columns were added in two places — the CREATE TABLE and the alterTable block — but the schema version was left at 19. Those ALTERs only ever run from onCreate or onUpgrade, and onUpgrade fires solely when that number increases, so on an existing database they never executed while the INSERT went on naming the columns regardless. The shape of the bug is why nothing caught it: a fresh install takes the CREATE TABLE path and works perfectly, so it is invisible in development and in any test that starts from an empty database. It only appears on a device that already had a logbook — which is every real user, and nobody running the tests. Bumped to 20 and gave the constant a name and a comment, since the failure mode is not obvious from the call site. The regression test builds a v19-era QSLTable by hand, stamps it with the old version, lets DatabaseOpr open it, and asserts the columns arrive — the upgrade path, not the create path, because the create path passed throughout the bug. A third case asserts every column doInsertQSLData names is present after upgrade, so the next column added without a migration fails here rather than in a car. Verified by reverting the constant to 19: all three tests fail. Restored to 20: all pass, and on the affected device the database upgraded in place to version 20 with both columns present and all 564 existing QSOs intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review on the upgrade test (PR #715) Both valid, both in the test rather than the fix. **The legacy database is now closed in a finally.** It was closed after an assertion that can throw, so a failing assertion leaked the handle — and on Windows an open handle keeps the file locked, which would then defeat the delete() each test does on the way out and leave the next test opening a database it believed it had created fresh. A flaky suite is a poor way to learn that. Pulled the setup into writeLegacyDatabase() rather than wrapping both copies: the second test had the same exposure through execSQL, and one helper removes the duplication and the leak together. It also means the 'the legacy table really lacks my_lat' precondition now guards both tests, where before only the first checked it. **columnsOf quotes the identifier and uses getColumnIndexOrThrow.** PRAGMA takes no bind parameters so the name has to be inlined, but it can at least be quoted. The index change is the more useful half: getColumnIndex returning -1 surfaces as getString(-1) failing with an opaque index error several frames from the cause. Re-verified the property the test exists for after restructuring it: reverting SCHEMA_VERSION to 19 fails all three, restoring 20 passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The program is now "Roads On The Air (ROTA)" at roadsontheair.com. This renames the Kotlin package, its classes, the string resources and all user-facing copy to match. Three things deliberately keep the old spelling, because they are state that already exists on someone's phone or in someone's files: - The EncryptedSharedPreferences filename stays "rtota_prefs". Renaming it would orphan every install's API key and, worse, its in-flight trip id and queued breadcrumbs. - ADIF now emits APP_ROTA_LAT/LON but still parses APP_RTOTA_LAT/LON on import, so archived exports and logs from older builds keep their exact rover coordinates instead of falling back to the rounded MY_LAT/MY_LON. - normalizeRotaBaseUrl() now rewrites a stored rtota.app origin to the new domain, so an install configured before the rename keeps uploading instead of failing against a host we no longer serve. Two changes go beyond a search-and-replace: The on-air CQ token becomes ROTA. It was the anagram RTOA only because RTOTA is five letters and an FT8 standard message encodes a CQ modifier of at most four; ROTA fits exactly, like POTA, so the workaround is retired and the token is finally the program's own name. maskKey() now cuts the API key at the prefix separator instead of a hardcoded six characters, which was exactly the width of "rtota_". Keys minted as "rota_" are a character shorter, and the fixed width would have printed a character of the secret itself on screen. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Start the announced trip instead of creating one beside it roadsontheair.com folded announcements into the trips table as a `planned` status, and deleted /api/activations. Three things follow. The announce call moves to `POST /api/trips` with `status: "planned"` and `name` (was `title`). The old path is a 404 now. `parseMyPlannedTrips` reads `plannedTrips[].name` off /api/me, was `activations[].title`. This one failed *silently*: the parser turns an unrecognized shape into an empty list, so the picker rendered "no upcoming trips" rather than an error. The keys are pinned in a test for that reason, including one asserting the old shape yields nothing. Picking a plan now binds. The plan is the same row the trip will be driven as, so Start promotes it by id (`POST /api/trips/:id/start`) rather than creating a second trip and leaving the announcement at `planned` forever. The wizard's privacy — delay, route trim, replay lock — therefore applies by construction. It used to be name-only, with the server guessing which plan a trip fulfilled from departure times within ±12 h; driving outside that window silently fell back to the account default. `activationMatchesNow` and the picker's window warning mirrored that guess and are gone. Promotion respects the deferred-create design, so a rover can still pull out of a driveway with no signal: the flush loop starts the plan when the network allows. Two failures are rover-normal rather than errors — 409 (a retry that actually landed, or another device) adopts the row, and 404 (the plan was cancelled on the site while out of coverage) falls back to a plain trip so the drive isn't stranded behind a queue that never drains. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Ask the server what an adopted trip holds (PR #717 Copilot review) The 409 branch said it would "let the resume handshake below establish what the server holds", but never armed it. `resumeHandshakePending` is only set in `restore()`, and set to `tripId.isNotEmpty()` — a trip still at `tripPendingCreate` has an empty id, so it is false there too, and `startTrip()` sets it false outright. Every path that reaches the 409 is therefore a path that skips the handshake. Adoption is exactly the case the handshake exists for. Its own doc draws the line at "did this process start the row" — and on a 409 it did not: another device started the plan, or a start whose answer we never saw landed. That row's contents are as unknown as one resumed from disk, so the queue should not be shipped without asking. The GET is cheap and already best-effort, so a failure still doesn't touch the backoff. The decision goes in a named function next to the outcome it reads from, with a test pinning the set of outcomes that arm it, so a fourth outcome has to answer the question rather than inherit "no handshake". Also fixes the KDoc on startPlannedTrip: the property is `httpCode`, not `code`, so the link didn't resolve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…0) (#668) * Show real VUCC grid-square progress on the Awards tab (was hardcoded 0) The Awards tab's VUCC (grid squares) card was hardcoded to current = 0, so operators chasing the grid-square award always saw "0 / 100" no matter how many unique grids they had logged. The Stats tab already computed the real count; the Awards tab just never received it. Extract the grid-square counter into a pure, testable internal gridSquaresWorked(grids: List<String?>) (upper-casing with Locale.ROOT so squares de-dupe correctly under a Turkish locale), surface the count through LogbookStats.gridSquares, and use it for both the Stats-tab bar and the Awards-tab card so the two views agree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Read the VUCC bar from stats, not a second computation (PR #668) The Stats tab's four award bars all read stats.*, except the VUCC one, which recomputed gridSquaresWorked(records) inline. Both derive from the same loaded list, so the number matched — but `records` is assigned as soon as the query returns, while `stats` is only built after the DXCC, zone, continent and state lookups finish. In that window the VUCC bar showed a real count beside four bars still reading their defaults. Reading stats.gridSquares makes the row update as a unit, drops a full pass over the log on every recomposition, and leaves one place computing the value. Also fixes the grammar of the test file's header comment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Optio Agent <optio-agent@noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sharing a POTA activation that has no QSOs crashed the app. The exporter runs on Dispatchers.IO, and the empty-documents exit reported failure by calling the caller's callback right there on the worker. The POTA screen's callback shows a Toast, and Toast.makeText needs a Looper, so it threw "Can't toast on a thread that has not called Looper.prepare()". The empty case is just the most reachable of four exits: the missing external-files-dir and the catch-all both did the same, and only the null-database early return — which never leaves the caller's thread — was safe. So the fix belongs in the exporter, not at the one Toast: a caller cannot see which thread its callback arrives on, and fixing the symptom would leave the next caller to rediscover this. Every callback now goes through deliverOnMain, which runs inline when already on the main thread and posts to the main Looper otherwise. That keeps the null-database path synchronous, as it was. startActivity stays on the IO thread — FLAG_ACTIVITY_NEW_TASK makes that legal, and only the callback had a main-thread requirement. Fixes #700 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#695) * Add a "New Prefix" (WPX) decode highlight + filter for prefix chasers Worked All Prefixes (CQ WPX) is one of the most-chased amateur-radio award programs, but until now the decode list could flag new DXCC entities, zones, states, grids and bands — not new callsign prefixes. This adds a "New Prefix" highlight pill and decode filter that mark CQ stations whose WPX prefix (e.g. W1, VE3, DL0) the operator hasn't logged yet, so prefix hunters can spot a new one at a glance and one-tap-filter the list down to only new prefixes. - WpxPrefix.of() is a pure, dependency-free CQ WPX prefix extractor (simple calls, no-numeral historic calls, portable numbers CALL/n, portable prefixes pfx/CALL, ignored /P /M /QRP suffixes; non-callsigns return null). Shared by the DB worked-prefix loader and the live decode predicate so they can't drift. - GetAllQSLCallsign builds a distinct worked-prefix set (any band), mirroring the existing worked-grid set. - New NEW_PREFIX status pill, isNewPrefixStation predicate, "New Prefix" filter chip + empty state, and a Settings → Decode Highlights toggle (off by default, like New Grid — early on most prefixes are "new"). Tests: WpxPrefixTest covers the extractor across simple/compound/edge cases; NewPrefixTest covers the predicate, the pill priority, and the filter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Derive the portable-number prefix from the base call's own prefix (PR #695) CALL/n lifted the leading letters off the raw token and appended the new number. That works only for calls whose prefix starts with letters and carries a numeral, which is what the tests covered. Two shapes it got wrong. A digit-leading call has no leading letters at all, so 9A1AA/7 lifted "" and returned null — a prefix chaser simply never saw it. A historic call with no numeral is all letters, so RAEM/4 lifted the whole token and produced "RAEM4", which is not a prefix. Both already resolve correctly as plain calls (9A1AA -> 9A1, RAEM -> RA0), so the portable form now runs the base through that same rule and swaps the trailing numeral: 9A1AA/7 -> 9A7, RAEM/4 -> RA4. Letter-leading calls are unchanged. It also inherits simple()'s conservatism, which the old path bypassed: W1/7 and FN42/7 are a bare prefix and a grid, not callsigns, and now stay null instead of inventing W7 and FN7. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Optio Agent <optio-agent@noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…719) QSL_MANUAL is not a field in the ADIF spec, so strict importers (LoTW's validator, Club Log, other loggers) can reject or silently drop it. PR #701 fixed this on Android by moving the flag to APP_FT8AF_QSL_MANUAL — the spec's APP_<PROGRAMID>_<FIELD> escape hatch — but the desktop and iOS ports were missed and still emit the bare name. Neither port needs the APP_ field, because on both the tag is a hardcoded "N" carrying no information: desktop's QSL_RCVD tracks r.confirmed, but QSL_MANUAL was always N, and iOS's QsoRecord has no confirmation state at all. Nothing reads it back either — desktop has no ADIF import path, and the iOS parser ignores QSL flags. Android's importer keys on the field being present, so an absent field and an explicit N are the same import. Dropping it is therefore lossless. Covers both desktop emitters: the file export and adif_record(), which is what goes out over the WSJT-X "Logged ADIF" UDP message to JTAlert/N1MM — the one most likely to meet a strict parser. Fixes #697 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#720) * Self-syncing clock: auto-trim the clock offset from decode DT medians Opt-in Time Sync setting that makes the band itself the time source: each slot's per-decode DTs (fast pass only, own-TX echoes excluded) feed a pure ClockSelfSync estimator - median with MAD outlier rejection, >=4 surviving samples, 0.30 s deadband (the UI's "good clock" threshold), and two consecutive same-sign slots required before acting. Corrections apply a 0.5 proportional gain (damps the measure->correct->measure loop; no hard step cap, per the GpsClockUpdater step-limiter post-mortem) and fan out through the same three-way path as the manual control (UtcTimer.delay, GeneralVariables.manualTimeCorrectionMs, timeCorrectionMs config row), so RX windows, TX key-up, and persistence all follow. Stands down (and clears its confirmation streak) while GPS clock discipline owns the clock; the settings row is disabled then too, matching the manual-correction lock-out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Harden ClockSelfSync slot handling against concurrent decode threads Copilot review on #720 flagged two real ordering hazards: beginSlot dedup'd only on equality (a slow slot's delivery arriving after its successor's would be treated as new), and the beginSlot + onSlotDecodes pair was called as two separate synchronized sections, letting adjacent- slot threads interleave between dedup and streak update. beginSlot now rejects any utc <= the last processed slot, and a new atomic onSlot(utc, dt, delay) does dedup + decision under one lock; MainViewModel uses it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ommands (#721) * Voice assistant v1: spoken event announcements + push-to-talk commands Opt-in hands-free layer for mobile/POTA passenger operation and accessibility. Announcements (TTS, per-event toggles) ride the existing DxAlertNotifier decode/QSO hooks: station calling you (with SNR), QSO logged, new-DXCC CQ, new-prefix CQ. Callsigns are spelled letter-by-letter so engines don't read them as words. Two hard audio-safety rules from the TX pipeline docs are enforced: - TTS never plays while the rig is keyed (it would be mixed into the TX audio and transmitted): the announcer refuses to start an utterance during TX, and a mutableIsTransmitting observer hard-stops in-flight speech at key-up. Suppressed announcements don't burn their dedup key. - The push-to-talk mic button is disabled whenever FT8 RX holds an Android audio-capture session (phone mic, or Android-routed USB input) - SpeechRecognizer would fight our capture. Direct-libusb USB and LAN audio leave it available. Commands are a small offline keyword grammar (answer / call CQ / stop / skip / log it) mapped onto the same entry points the UI buttons use (callStation, userResetToCQ + setActivated, forceLogAndMoveOn), with a pure newest-caller selector for "answer" and a spoken echo of each action. New Voice Assistant settings category (5 toggles, config-table persistence with hydration arms); <queries> entries for RecognitionService and TTS_SERVICE. 59 new unit tests, all pure JVM. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Make the voice-command mic button react to its settings toggle live Device smoke test caught the button only appearing/disappearing after an app restart: the Composable read GeneralVariables.voiceCommandsEnabled as a plain static, which nothing invalidates. Add the house-pattern LiveData mirror (mutableVoiceCommandsEnabled) updated by the settings toggle and config hydration, and observe it from VoiceCommandButton. Verified on hardware: toggling now shows/hides the button immediately both ways. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address Copilot review on the voice assistant (PR #721) - Suppress the recognizer-error toast for ERROR_CLIENT: it's what cancel() (a deliberate second tap) emits, so toasting it made a user-initiated cancel look like a failure. Mapping extracted to the pure voiceErrorToastRes() and covered by tests. - VoiceAnnouncementDecisions.norm() now uppercases with Locale.ROOT so dedup keys are stable regardless of device locale (Turkish dotted-I regression test added). - Mic-gate strings no longer claim only the "phone mic" is the blocker - the gate also covers Android-routed USB input, and the wording now says so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Log SSB contacts by hand during a ROTA trip FT8 contacts log themselves, but a rover who picks up the mic had no way to get a voice contact into the log, onto the trip map, or up to roadsontheair.com. The active-trip card now has a Log SSB contact button opening a quick-entry dialog: callsign, frequency (band derived and shown for confirmation), RST defaulting to 59/59, optional grid. The dial frequency is remembered across entries, since a rover typically camps on one frequency for a run of contacts. The dialog builds the same QSLRecord the FT8 path builds and hands it to DatabaseOpr.addQSL_Callsign, so the GPS position stamp, the ADIF mirror and the ROTA live upload all come for free. Also fixes RST formatting for non-SNR modes everywhere reports are rendered: a 59 sent on SSB was formatted "+59" (the WSJT-X SNR convention) in the DB, the ADIF mirror, third-party uploads, the WSJT-X UDP broadcast and the ROTA wire. Reports now format signed only for the digital modes that actually report SNR, so the live copy and the end-of-trip ADIF copy of an SSB contact agree and dedupe into one row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Disable the SSB log button until the entry validates (PR #724) Copilot review: the always-enabled button silently swallowed taps on an invalid entry. It now disables (and drops its accent color) until ssbEntryValid passes; the onClick guard stays as a stale-recomposition backstop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…#726) * Protect a pending band selection from being overwritten by rig echoes Field failure (2026-08-04, FT-891 on a flapping USB link): band taps were silently ignored, and the app re-commanded the OLD band every ~2 minutes all evening. Trace: 20:19:40 bandSelect: band=10136000, rigConnected=false <- operator taps 30m 20:19:41 setOperationBand: rig not connected, skipping <- FA never dispatched 20:19:4x poll reads the rig, still on 14074000 <- healthy stream -> adopted as commandedBandHz <- the tap is ERASED 20:23:35 serial.send: FA014074000; <- heartbeat re-asserts 20m, against 30m taps The RigDialTarget desync window (the earlier 59-second-band-change fix) can't catch this: the stream is healthy and the reading truthful -- the rig really is still on the old band, precisely because the connected-gate dropped the operator's command before it reached the wire. An explicit operator selection is now tracked as PENDING (operatorDialAssertedAtMs) until the FA is actually dispatched (operatorDialDeliveredAtMs) plus a short settle grace (CONFIRM_GRACE_MS); while pending, a differing rig report is refused as a command target, so the reassert heartbeat keeps pushing the operator's choice and delivers it automatically once the link recovers. A report matching the commanded dial clears the pending state, and after the grace a still-differing report is followed again (hand-turned VFO must win). Also fixes the two legacy band-selection paths (ConfigFragment spinner, FreqDialog) which never set commandedBandHz at all, so setOperationBand re-commanded the stale dial; all entry points now go through GeneralVariables.operatorChoseDial(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address review: reset delivery stamp on new selection; stamp only real writes Two review findings on the pending-selection guard: - operatorChoseDial() now zeroes operatorDialDeliveredAtMs. deliveredAt < assertedAt usually marks a new selection undelivered, but this app's clock is GPS-disciplined and can step backwards, which could leave an older delivery stamp at or beyond the new assert stamp. - The delivery stamp is only advanced when the CAT write actually reached the rig. CableSerialPort.sendData() returns false on a dead port without throwing; stamping that as delivered would start the confirm grace on a command the rig never saw. New isLastCatWriteOk() on the connector chain (same posture as isLastPttWriteOk), consulted via the pure RigDialTarget.deliveredStamp(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ap (#727) * Stop USB permission dialog storms and reconnect-loop leaks on link flap Field failure (2026-08-04): "the app keeps constantly asking for USB permission... and it's doing it when I disconnect the USB cable." Three compounding causes, one per fix: 1. The auto-reconnect loop was unbounded even after the device left the bus. Unplugging re-enumerates the devices several times on the way out, and each bounce spawned fresh connect attempts. The loop now stops when the device is gone (CatReconnectPolicy.shouldKeepRetrying) and surfaces the disconnect; the USB ATTACH broadcast restarts auto-connect when the device returns, so nothing is lost. 2. The "already asked for permission?" guard was per-CableSerialPort instance, and every auto-connect builds a fresh instance — so a flapping link raised a system dialog per bounce, for both the CAT serial and the C-Media audio device. UsbPermissionThrottle is a process-wide per-vendor cooldown (30 s) that outlives any port instance; both request sites now consult it. 3. connectCableRig never tore down the previous connector, leaking its open port and its reconnect loop on every re-enumeration — measured as an orphaned port's poll timers spamming "port not open!" interleaved with the live port's sends, plus stacked concurrent reconnect loops. The old connector is now disconnect()ed (which ends its loop via userDisconnected) before a new one is created. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address review: monotonic throttle, package-private helper, port teardown - UsbPermissionThrottle.markRequested() uses merge(max) so two threads racing (a bounce re-enumerates both serial ports near-simultaneously) cannot move the stamp backwards and shorten the cooldown. - CableSerialPort.isDevicePresent() is package-private: an internal reconnect helper, not part of the port's supported surface. - The auto-reconnect loop's device-gone exit now calls cableSerialPort.disconnect() instead of only notifying: each connect() attempt re-registers the permission-grant receiver via prepare(), so exiting without disconnect() leaked it. disconnect() fires onDisconnected itself, which also moves the UI out of "connecting". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…erver (#728) * ROTA: let the trip picker re-attach to a trip already active on the server A reinstall cannot carry the in-flight trip attachment across: it lives in Keystore-encrypted prefs whose master key dies with the uninstall. Measured 2026-08-04 — the operator's trip was still active server-side (716 mi / 138 QSOs that day) but the picker only listed `planned` trips, leaving no way to rejoin it. /api/me carries the operator's active trips under `recentTrips` (mixed active+completed rows with a `status` field; verified against the live server). The picker now fetches both halves in the same GET (RotaClient.fetchMyTrips) and shows active trips in an "On the road now" section, most recently started first, with progress (QSOs / miles) so the row reads as "this is the drive you're on". Picking one calls RotaTripManager.continueTrip(), which mirrors restore() rather than startTrip(): nothing to create server-side, sampler starts fresh (the tracking gap is real), and the resume handshake reconciles via sync-state before anything is re-sent — the same adopted-row posture as the plan-start 409 path. Also covers a second device joining a drive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address review: round picker miles instead of truncating 716.55 read as 716 — a rover's odometer going backwards relative to what the site reports. Extracted wholeMiles() so the rounding is pinned by a unit test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promote dev → staging
Rolls the current
dev(3bb6b96b) up tostaging.devis 97 commits ahead ofstagingsince the last promotion (#613).Highlights
New features
Rig / CAT fixes
Stability / crash fixes
Full commit list on the PR's Commits tab.