Skip to content

Promote dev → staging - #679

Merged
patrickrb merged 140 commits into
stagingfrom
dev
Aug 9, 2026
Merged

Promote dev → staging#679
patrickrb merged 140 commits into
stagingfrom
dev

Conversation

@patrickrb

Copy link
Copy Markdown
Owner

Promote dev → staging

Rolls the current dev (3bb6b96b) up to staging. dev is 97 commits ahead of staging since the last promotion (#613).

Highlights

New features

Rig / CAT fixes

Stability / crash fixes

Full commit list on the PR's Commits tab.

patrickrb and others added 30 commits July 20, 2026 19:01
…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("<", "&lt;")` 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>
patrickrb and others added 29 commits July 24, 2026 10:30
…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>
@patrickrb
patrickrb merged commit 57c23ad into staging Aug 9, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant