Skip to content

Promote dev → staging#602

Merged
patrickrb merged 278 commits into
stagingfrom
dev
Jul 20, 2026
Merged

Promote dev → staging#602
patrickrb merged 278 commits into
stagingfrom
dev

Conversation

@patrickrb

Copy link
Copy Markdown
Owner

Promotes the current dev branch to staging for release candidacy.

Scope

277 commits, 376 files (+45,081 / −2,209). Highlights:

iOS parity

Desktop

Android / general

Robustness / infra

Release checklist

  • CI green on dev
  • Smoke-test Android build on device
  • Smoke-test iOS build
  • Smoke-test desktop build

patrickrb and others added 30 commits July 8, 2026 18:44
- CrashReporting.start: init with context.applicationContext (setEnabled can
  pass an Activity context, which the SDK would hold for its lifetime = leak).
- CrashReporting release now includes +versionCode, matching the Sentry Gradle
  plugin/SDK default (<applicationId>@<versionName>+<versionCode>) so runtime
  events and the uploaded mapping.txt line up.
- ComposeMainActivity.fileLog: add a breadcrumbMsg override (full line still
  goes to debug.log/logcat); redact the callsign in handleAlertIntent's
  breadcrumb so a callsign (PII here) never rides along with a crash report.
- CrashReportingTest: rephrase the isAvailable comment — SENTRY_DSN comes from
  a Gradle prop/env, so it may be present locally/CI; the assertion holds either
  way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B1egpdNWafvAksJCn1tMA
getCallsignTo() guarded only `callsignTo.length() < 2` before running
`callsignTo.substring(0, 3)` for the "QRZ" calling-token check. Because
`||` short-circuits, any callsignTo of exactly length 2 that is neither
"CQ" nor "DE" fell through to substring(0, 3) and threw
StringIndexOutOfBoundsException.

callsignTo is populated directly by the native decoder, and FT8 frames
carry only a 14-bit CRC, so occasional noise slips through as a "valid"
decode that renders a short junk token into the callsign field (the same
CRC-collision garbage isPlausibleCallsign/isJunkDecode already guard
against for the sender field). getCallsignTo() runs on essentially every
decoded message (alerting, TX sequencing, callsign DB, UI adapters), so
the crash sits on the decode-handling hot path.

Replace the substring comparisons with startsWith, which is safe for any
string length and preserves the existing calling-token semantics. Add
regression tests for the length-2 and length-1 cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opt-in Sentry crash reporting with deobfuscated release traces
…bbc1-2f0c7ed83b03

Fix StringIndexOutOfBounds crash in Ft8Message.getCallsignTo()
formatCallsign's else branch ran substring(0, 3) after only length>3
guards on the earlier Swaziland/Guinea branches, so any callsign of
length 1 or 2 (that is not a CQ/DE/QRZ token, which pack_c28 short-
circuits earlier) fell through and threw StringIndexOutOfBoundsException.

pack_c28() calls formatCallsign() on the raw callsign BEFORE deciding
whether it is a standard callsign, and generatePack77_i1/_fd feed it the
message's callsignTo/callsignFrom. FT8 frames carry only a 14-bit CRC, so
a noise-driven false decode can render a short junk token into a callsign
field -- the same CRC-collision garbage getCallsignTo()/isPlausibleCallsign
already guard against on the decode side (fixed in 731e0eb). Calling
(replying to) a station whose decoded callsign came back as e.g. "W1" runs
generatePack77_i1 -> pack_c28("W1") -> formatCallsign("W1") -> crash. Over
the TX-encode path this is an uncaught exception; the disabled
callsignFrom-length guard in GenerateFT8.generateFt8 never covered the "to"
field anyway.

Add a length>=3 guard before the substring(0, 3) comparison (a valid
[A-Z][0-9][A-Z] prefix needs 3 chars, so short callsigns simply pass
through and get space-padded, then take pack_c28's non-standard hash path).
Length>=3 behavior is unchanged. Make formatCallsign package-private so
the regression tests can exercise it directly -- pack_c28 on a non-standard
callsign falls through to the native getHash22 branch, which a plain-JVM
unit test cannot load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
IcomCommand.getData() returns null when a CI-V frame has no data section
(e.g. a short/garbled/echoed frame such as FE FE E0 A4 03 FD). Three
decoders dereferenced that result without a null check:

- IcomCommand.getFrequency() read data.length
- IcomRigConstant.twoByteBcdToInt()/twoByteBcdToIntBigEnd() read data.length

A malformed serial response therefore threw NullPointerException on the
CAT thread (and, for Bluetooth rigs whose RX bytes are parsed on the main
thread with no try/catch, crashed the app). These are hit per CAT poll via
IcomRig/XieGuRig frequency and ALC/SWR meter handling.

Each method already guarded the too-short case (return -1 / 0); extend the
same guard to cover null so a bad frame is ignored rather than crashing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The HANDLE branch of X6100Radio.doReceiveLineEvent parsed the Xiegu
client handle with an unguarded Integer.parseInt(head.substring(1), 16).
Every sibling parse in this class (seq_number, resultCode, play_volume)
and the identical handle parse in the Flex sibling (FlexRadio) wrap the
same call in a try/catch; the X6100 variant moved the parse out of the
XieguResponse constructor and lost the guard.

Two inputs make it throw NumberFormatException:

  * a garbled or truncated frame whose tail is not hex — even a lone
    "H", whose tail is the empty string; and
  * a legitimate high-bit 32-bit handle such as "HFFFFFFFF": the Xiegu
    protocol handle is a full 32-bit value (see the HANDLE enum doc) and
    0xFFFFFFFF overflows a signed int, so Integer.parseInt rejects it.

That parse runs on the TCP CAT read thread (RadioTcpClient.SocketThread),
whose read loop catches only SocketException/IOException. A
NumberFormatException escaping the listener therefore propagates out of
run(), killing the read thread and crashing the whole app via the
default uncaught-exception handler.

Extract the parse into a package-private static parseXieguHandle(head,
currentHandle): it length-guards the input, parses via Long.parseLong so
the full unsigned 32-bit range round-trips into the int handle field,
and contains any remaining malformed input by keeping the current handle
(matching FlexRadio). The handle field is write-only in this class, so
preserving the prior value on a bad frame is behaviourally safe.

Add X6100RadioHandleParseTest covering low handles, full 8-digit and
high-bit handles (the 0xFFFFFFFF / 0x80000000 overflow cases that used
to crash), and the lone-"H"/non-hex/empty/null frames.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The numeric CAT meter parsers on Yaesu3Command (getALCOrSWR38,
getSWROrALC39, get590ALCOrSWR) and ElecraftCommand (getSWRMeter) call
Integer.parseInt on a fixed substring of the rig's reply, validating only
the token length and never that the substring is numeric. get590ALCOrSWR
additionally had no length guard at all.

These parsers run per-CAT-poll inside onReceiveData. On the Bluetooth SPP
path (BluetoothSerialService -> BluetoothRigConnector.onSerialRead ->
BaseRig.onReceiveData) that runs on the main thread with no surrounding
try/catch, so a garbled or non-numeric meter reply threw
NumberFormatException (or StringIndexOutOfBoundsException for the
unguarded get590ALCOrSWR) and hard-crashed the app.

Treat malformed meter data as 'no reading' (0) instead of throwing,
matching the existing getFrequency() convention in the same files. 0 is
the safe sentinel: it never falsely trips the SWR-halt / ALC protection,
which key off high values.

Add CatMeterParserTest covering numeric, non-numeric, and short data for
all four parsers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot review on PR #488: parsing via Long.parseLong alone was too
permissive for an unsigned handle — it accepted a leading sign
("H-1" -> -1 instead of keeping the current handle) and accepted more
than 8 hex digits and then silently truncated on the narrowing int cast
("H100000000" -> 0). The error log also copied FlexRadio's misleading
"parseInt"/"XieguResponse" wording.

Validate the tail as 1-8 unsigned hex digits (isUnsignedHex) before
parsing; reject anything else and keep the last good handle. After
validation Long.parseLong can't throw, and 0x80000000..0xFFFFFFFF still
round-trip into the int field with the same 32-bit pattern. Log an
accurate message naming the offending tail.

Add tests for lowercase hex, a signed tail, an overlong (9-digit) tail,
and a whitespace tail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t read)

When "Save SWL QSO" (GeneralVariables.saveSWL_QSO) is enabled, MainViewModel's
afterDecode handler passed the live, shared ft8Messages list to
SWLQsoList.findSwlQso as the "all messages" argument. findSwlQso scans that list
with index-based backward loops (checkPart1/checkPart2: for i = size()-1 .. 0,
get(i)) and did so OUTSIDE the synchronized(ft8Messages) block.

The decode pipeline intentionally runs two decode threads concurrently: slot N's
late full-slot / deep pass overlaps slot N+1's early pass (issue #398, see the
comment at MainViewModel:169-170 and FT8SignalListener's late pass). Both call
afterDecode; the writer holds synchronized(ft8Messages) while doing addAll +
GeneralVariables.deleteArrayListMore, whose remove(0) shifts and shrinks the
list. With the SWL scan reading get(i) off-lock on the other decode thread, a
concurrent remove(0) can drop the size below an already-computed index and throw
IndexOutOfBoundsException, crashing the decode thread.

Fix: copy ft8Messages into a snapshot under the same monitor the writers use
(the exact pattern publishFt8MessageList already uses) and scan the snapshot. The
newly decoded messages were appended to ft8Messages under lock earlier in the
same handler, so they are already in the snapshot and QSO detection is unchanged.

Root cause is the off-lock read of the shared list, not findSwlQso itself, so the
fix is at the call site (the snapshot must be taken under the writers' lock; a
copy inside findSwlQso would itself race the live list during the copy).

Add SWLQsoListTest covering findSwlQso's QSO-detection branches (complete QSO with
both reports, missing-report rejection, own-callsign skip) plus an explicit
equivalence test showing findSwlQso is a pure function of the two lists it is
given, so scanning a defensive copy yields the same result as scanning the live
list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…a274-6211d82ca3de

Fix NPE crash in ICOM CI-V frequency/meter decoders on short frames
…9877-6d5e795735a0

Fix CAT meter parser crash on non-numeric rig replies
…nce test

Copilot: scanningACopyMatchesScanningTheLiveList indexed found.get(0) after only
asserting size equality, so a regression to 0 records would throw
IndexOutOfBounds instead of failing on a clear assertion. Assert both callbacks
captured exactly 1 record before dereferencing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Javadoc claimed the method formats a "standard callsign" and returns
"the C28 value represented as an int", but it returns a space-padded c6
string and is invoked by pack_c28() on the raw callsign before the
standard-callsign check — so short/non-standard tokens reach it too.
Update the doc to describe the real contract and return type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…b16a-02b366a75140

Fix StringIndexOutOfBounds crash in FT8Package.formatCallsign()
…bb72-3cd33619d4a7

Fix app crash on malformed / 32-bit Xiegu CAT handle frame
…ac16-0fac01ed0644

Fix decode-thread IndexOutOfBounds in SWL QSO scan (off-lock live-list read)
FlexMeterInfos.setMeterInfos() parsed the meter definition list with
    content.substring(content.indexOf("meter ") + 6)
and no guard on the indexOf() result. When a corrupt or truncated
METER_LIST frame does not contain "meter ", indexOf() returns -1, so
the offset becomes 5 and substring(5) throws
StringIndexOutOfBoundsException on any frame shorter than 5 chars.

This runs on the FlexRadio TCP read thread (RadioTcpClient.SocketThread),
whose read loop catches only SocketException/IOException, so the
RuntimeException escapes and crashes the whole app.

Root cause: missing validation that the "meter " keyword is present
before slicing the string. Fix returns early when the keyword is absent
(a non-meter frame has no meter definitions to parse), which also stops
non-meter frames from being parsed as garbage.

Adds FlexMeterInfosTest (pure JVM) covering the crash regression, empty
input, keyword-at-end, non-meter frames, well-formed parsing, and
in-place re-parse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…914f-7a1373b9efea

Fix FlexRadio meter-list parse crash on frame without "meter " keyword
Add an opt-in "Use my location" flow to Settings → Station that fills the
operator's Maidenhead grid from the computer's OS location service, instead
of only manual entry (issue #471).

- Off by default. Gated behind a new `location_service_enabled` config flag
  (default false); the "Use my location" button only appears once the user
  turns on "Use OS location service", and nothing requests location — no
  permission prompt — until the button is pressed.
- New `get_os_location` Tauri command refuses unless the flag is on
  (defense in depth), queries the platform service, and returns lat/lon plus
  a derived 6-char (subsquare, ~2.5×5 km) Maidenhead grid.
- Platform backends, target-gated so each OS compiles only its own:
  Linux = GeoClue 2 over D-Bus (zbus), Windows = Windows.Devices.Geolocation.
  macOS/other returns a clear "enter manually" error for now (CoreLocation
  delegate + Info.plist key is a documented follow-up).
- The OS query is bounded by a 15 s timeout so the button can't hang the app;
  on any failure the manual grid field is left untouched and the error shown.

Tests (Rust): latlon_to_maidenhead reference points / precision clamping /
grid4 compatibility; location_enabled gating (default off, only exact "true"
enables) and to_os_location grid derivation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getCallsigns guarded on contains(")") but cut at indexOf("("). A prefix
token carrying a ')' with no '(' made indexOf("(") return -1, so
substring(0, -1) threw StringIndexOutOfBoundsException. Because
getCallsigns runs inside CallsignDatabase's per-country try/catch, that
exception silently dropped every callsign for the affected country.

Guard on '(' instead — the delimiter actually indexed — mirroring the
adjacent '[' branch. Well-formed "(cq)" tokens strip identically; a
stray ')' is now left intact instead of crashing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The .field label rule forces display:block, overriding the .row class, so
set flex inline to keep the checkbox and its text aligned on one row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause: com.k1af.ft8af.log.HashTable — the two-key table SWLQsoList uses
to track already-logged SWL QSOs — was a stub. Its contains() always returned
false and put() was a no-op, so SWLQsoList.findSwlQso's dedup guard
(!qsoList.contains(from, to)) was always true and put() never recorded
anything. With "Save SWL QSO" enabled, every repeat of a closing marker
(RRR/RR73/73) for the same contact — common as stations repeat until answered —
re-detected the QSO and re-wrote it to the database plus re-showed the toast,
flooding the SWL QSO log with duplicates.

Fix: back HashTable with guava's HashBasedTable (already a dependency, declared
for exactly this class), delegating every Table method. Callsign keys can be
absent and findSwlQso runs on the decode thread, so null keys/values are
treated as "not present"/no-op instead of throwing (guava tables reject null),
preserving the stub's crash-free tolerance while restoring real dedup.

Tests:
- HashTableTest (pure JVM): put/contains round-trip, directional keys, null-key
  tolerance.
- SWLQsoListTest.repeatedClosingMarker_isDedupedAcrossCycles: two findSwlQso
  passes on one instance now fire the callback once.
Both fail against the old stub. Full :app:testDebugUnitTest passes and
:app:assembleDebug builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FlexMeterList.setMeters() already guards the unknown-meter case
(infos.get(val) == null) but then unconditionally dereferences
meter.name.contains("PWR") in the dBm/swr branch.

FlexMeterInfo.nam is left null whenever the meter-definition frame
carried a .unit token but no (or an empty) .nam token — e.g. a partial
or split METER_LIST frame that supplies "N.unit=dBm" for a meter whose
.nam was never seen (the existing FlexMeterInfosTest.reparseUpdates...
case exercises exactly this "unit without name" shape). Reaching the
dBm/swr branch for such a meter threw an uncaught NullPointerException.

Meter value frames are parsed on the FlexRadio meter-stream read thread,
whose loop catches only SocketException/IOException (same context as the
FlexMeterInfos crash fixed in PR #491), so the escaping NPE crashed the
whole app on every subsequent meter update.

Root cause: unsafe coupling — the consumer assumes .nam and .unit are
always present together, but FlexMeterInfo populates them independently.

Fix: null-guard meter.name before contains("PWR"). A nameless meter can't
be identified as a PWR meter, so it simply keeps its raw dBm value, which
is the correct conservative behavior.

Adds FlexMeterListTest (pure JVM, no Robolectric) covering the nameless
dBm and swr meters (the crash), plus the named FWDPWR dBm->watt
conversion, a named non-PWR dBm meter, and the unknown-id skip guard to
lock in existing behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…frames

ColumnarView.setWaveData rebuilt the peak-hold "falling block" list to the
CURRENT frame's bin count (binsToShow) but the fall loop read positions from
the PREVIOUS frame's bar list (newData). When the visible bin count grew
between two frames — a spectrum-width change (NumberInputDialog, 2500-5000 Hz)
or an audio-sample-rate change while the peak blocks are shown — the loop
indexed past the previous bars and threw IndexOutOfBoundsException. Drawing
runs on the UI thread with no try/catch, so that is a hard app crash.

Root cause: the rebuild sized to binsToShow while the copy source (newData)
still held the previous frame's, smaller, bar set.

Fix: drive the peak-hold state off the bars actually present this frame. The
geometry/sizing decision is extracted into a pure, JVM-testable PeakBlocks
helper (mirroring the BinAggregation extraction) that always returns one block
per current bar; a bin-count change resets the peak-hold to the floor, matching
the legacy full-rebuild. When the bin count is unchanged the geometry is
bit-for-bit identical to the pre-fix code.

Tests: PeakBlocksTest covers the growing-bin-count crash (asserts the legacy
inline logic throws and the fix does not), first-frame/rise/fall math, the
shrinking-bin-count reset, and bit-for-bit parity with the legacy math for the
stable-size case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Yaesu2Command.getFrequency decodes the 4 BCD bytes the FT-817-family CAT
protocol returns for the operating VFO. The eight nibbles are a strictly
descending decimal sequence (1e8, 1e7, ... 1e1), but the last two nibbles
(byte 3, the hundreds- and tens-of-Hz digits) carried copy-pasted weights
of 10000 and 1000 instead of 100 and 10 — the same weights already used by
byte 2's 10 kHz / 1 kHz digits.

As a result the reported VFO frequency was corrupted by tens of kHz whenever
the rig reported a non-zero hundreds- or tens-of-Hz digit (e.g. a dial not on
an exact kHz boundary), which can mis-detect the band and mis-display the
frequency. Exact-kHz dials have byte 3 == 0x00, so the common FT8 case was
unaffected and the pre-existing test (which used byte 3 == 0x00) still passed.

The corrected weights make the decoder the exact inverse of
Yaesu2RigConstant.setOperationFreq for 10 Hz-aligned frequencies. Fix is
shared by Yaesu2Rig and Yaesu2_847Rig, which both call this decoder.

Tests: added a non-zero-byte-3 case and an encoder round-trip case to
Yaesu2CommandTest (both failed before the fix, pass after); full
testDebugUnitTest suite and assembleDebug are green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prior commit added the regression tests but never changed the
production decoder, so getFrequency still weighted byte 3's nibbles at
10000/1000 instead of 100/10 (the tests would have failed against the
committed source). Correct the multipliers so the decoder actually
inverts the encoder for 100 Hz-aligned dials, and reword the test-class
Javadoc: setOperationFreq packs the last nibble as freq % 100, so the
two are exact inverses only for 100 Hz-aligned frequencies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B1egpdNWafvAksJCn1tMA
The test only asserted the sibling prefix survived. Tighten it to
containsExactly("BAD)", "VE7") so it also verifies the malformed token
is preserved intact and no extra entries leak in — preventing a future
change from silently dropping the malformed token while still passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B1egpdNWafvAksJCn1tMA
Two review follow-ups on the SWL dedup table:

- Concurrent decode passes can overlap (MainViewModel slot N late/deep
  vs slot N+1 early, #398), so contains/put/remove run from two decode
  threads at once. HashBasedTable is not thread-safe; wrap the delegate
  in Tables.synchronizedTable so concurrent mutation can't corrupt the
  backing HashMap.
- row(null)/column(null) forwarded straight to guava, which throws NPE
  despite the class's documented null-tolerance. Return an empty map for
  null keys instead, matching contains/get/remove.

Adds nullKeyRowAndColumn_returnEmptyMapNotThrow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B1egpdNWafvAksJCn1tMA
RadioTcpClient.SocketThread.run() catches both SocketException and
IOException (RadioTcpClient.java:151-153), not only IOException. The NPE
escapes regardless because neither catches RuntimeException, so the fix
is unchanged — this just makes the inline note accurate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B1egpdNWafvAksJCn1tMA
patrickrb and others added 27 commits July 13, 2026 09:14
…a9d1-3c9f62f9edee

Honor WSJT-X Reply df on Android (answer on the requested audio tone)
…a988-b115b34c745c

Fix Flex TCP CAT sends racing on a shared runnable + OutputStream
…b760-b026bb758d8f

Fix WSJT-X inbound Free-Text crash (null TX target) and repeating send
…a94e-5448c183b1c8

Fix CallingListAdapter main-thread crash on the live decode list (concurrent IndexOutOfBounds)
…bbd0-03963077df6e

Desktop: bound the callsign hash-table probe and reset it per slot (fix decoder-thread hang)
…8e66-3a76f60d76d4

Fix iOS waterfall frequency axis (overlays assumed 3000 Hz while data spans 3500 Hz)
…8c5c-aa24bbd59dd4

Fix iOS map/distance: reject the RR73 QSO sign-off in gridToLatLon
…bc3f-99feed4289b4

Desktop WSJT-X UDP: honor an inbound Reply's requested df (answer on the asked tone)
…9da9-153631d2726c

Desktop: send the answerer's courtesy 73 (fix dead-write in QSO complete())
…ba8f-72e21099f663

Fix Grid Tracker calling-list tap crash on the live decode list (main-thread IndexOutOfBounds)
…t a 500 multiple (#575)

The Compose FrequencyRuler laid its 0,500,…,≤spectrumWidth labels out in a
Row with equal weight(1f) spacers, putting the first label at x=0 and the top
label at the far right edge (fraction 1.0). That is only correct when
spectrumWidth is a multiple of the 500 Hz label step.

The waterfall/columnar views map a signal at hz to fraction hz/spectrumWidth
(WaterfallView.freq_width = width / spectrumWidth), and the width picker is a
free NumberInputDialog (min=2500,max=5000) with no 500 Hz step. So for e.g.
spectrumWidth=2750 the labels ran 0…2500 with "2500" jammed at the right edge,
where 2750 Hz actually sits — the ruler ticks drifted up to ~9% from the
traces, the red TX marker and tap-to-tune.

Fix: extract a pure, unit-testable rulerTicks(spectrumWidth) that pairs each
label with its true fraction hz/spectrumWidth, and position the labels by
fraction-gap spacer weights plus a trailing spacer for the remainder past the
top tick. Byte-identical for 500-multiple widths (uniform gaps, zero trailing).

FrequencyRulerTicksTest added (pure JVM). Verified with :app:testDebugUnitTest
and :app:assembleDebug (all 4 ABIs).

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…583)

An inbound WSJT-X Reply (companion apps like GridTracker/JTAlert/N1MM, when
"Accept UDP requests" is on) carries a `df` = the audio Hz it wants us to
answer on. `LiveEngine`'s `onReply` handler adopted any `deltaFreq > 0`
straight into `txFreqHz` with no bound. The decoder's band runs to 3500 Hz,
so double-clicking a routine 3000-3500 Hz spot sends a df above the SSB TX
passband; a stray/garbled datagram can send anything up to UInt32.max.

The result is a reply keyed up outside the passband (attenuated / effectively
off-air) while the TX marker is separately pinned to the band edge by
`WaterfallAxis.clampedFraction` -- so the marker and the actual tone disagree.
The desktop (`reply_tx_audio_hz`, #564) and Android (`replyTxFrequencyHz`,
#563) ports already bound this untrusted df; the iOS port dropped that guard.

Root cause fix: add pure `WaterfallAxis.boundedReplyTxHz` (Foundation-only,
Kit-testable) that honors a df only inside [minTxHz, maxTxHz] = [200, 3000] --
the same range `tunedTxHz` clamps tap-to-tune to -- and returns nil (keep the
operator's current offset) for 0/unspecified or out-of-band requests. In-band
replies behave exactly as before; the app's own click-to-answer (df=0) is
unchanged.

Tests: WaterfallAxisTests gains in-band-honored and dropped-out-of-band cases
(4 fail against the old unbounded behavior). Full FT8AFKit suite 131/131 via
swift test on Linux. The onReply wiring lives in the Xcode-only app target
(not Linux-buildable); the extracted helper + its tests are.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…585)

The always-on LogHttpServer (started at app launch, LAN-reachable) read the
?page=, ?pageSize= and ?session= query parameters with a raw Integer.parseInt
and clamped pageIndex only on the high side. Two defects on untrusted input:

- A non-numeric value (?page=abc, a hand-edited URL, a stale bookmark, a
  truncated link) threw NumberFormatException out of serve(). NanoHTTPD's
  ClientHandler swallows it (logs, closes the socket), so the browser got an
  aborted/empty response instead of the requested logbook view.
- pageIndex was clamped with `if (pageIndex > pageCount) pageIndex = pageCount`
  but never floored, so ?page=0 (or a negative page) left pageIndex < 1 and the
  paged query's `LIMIT (pageIndex - 1) * pageSize, pageSize` offset went
  negative — a wrong/empty result set numbered from a negative "No." column.

Route every query-param parse through a new pure static parseQueryInt(value,
fallback) (falls back on null/non-numeric) and every page-index clamp through
clampPageIndex(pageIndex, pageCount) (floors to 1; pageCount is always >= 1).
Applied at all three paged views (getMessages/getSWLQsoMessages/getQsoLogs) and
both import-session handlers. Behaviour is byte-identical for well-formed
requests; malformed ones fall back to a sane default instead of breaking.

Both helpers are pure and covered by LogHttpServerQueryParamTest (pure JVM,
mirrors LogHttpServerPageCountTest); the guard cases fail against the pre-fix
behaviour. Verified :app:testDebugUnitTest + :app:assembleDebug (4 ABIs) green.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause: the auto-sequencer's classify() checked the grid field before
matching the RR73/RRR/73 sign-offs. "RR73" is a valid 4-char Maidenhead-shaped
token (R,R in A..R followed by two digits), so the decoder copies it into the
grid field (looks_like_grid("RR73") is true, dsp/decoder.rs). classify() then
returned Content::Grid("RR73") for a standard WSJT-X sign-off, so the RReport
stage in process_rx hit the `_ => {}` arm and the engine retransmitted our
R-report forever — the QSO never completed or logged. Only non-grid-shaped
sign-offs (73, RRR) closed a QSO.

Fix: match the RR73/RRR/73 sign-offs on the trimmed extra text BEFORE the grid
check in classify(). looks_like_grid is left untouched (the map/distance code
relies on its shape test and rejects RR73 separately).

Tests: added rr73_signoff_is_not_classified_as_a_grid (direct classify unit
test) and rr73_with_decoder_grid_still_completes_qso (full RReport->complete
flow with the realistic grid=="RR73" the decoder emits). Also corrected the two
existing RR73 fixtures, which passed grid=="" (never happens live) and so
masked the bug, to grid=="RR73". All four fail against the pre-fix classify and
pass after.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)

* Fix logbook grid-coverage heatmap dropping the northern hemisphere

The GridSquareHeatmap in the logbook Stats screen drew one cell per
Maidenhead field but iterated only 10 latitude rows (`rows = 10`),
generating latitude fields A..J = latitudes <= +10°N. Both Maidenhead
field axes span A..R (18 divisions of 20° longitude / 10° latitude), so
every worked grid north of +10° — latitude field K onward, i.e.
essentially all of North America, Europe, and Japan (e.g. FN, JO, PM) —
was never generated and could never highlight, leaving the heatmap
blank across the most-worked bands.

Root cause: the latitude field is a letter (A..R), not a digit; the loop
bound treated it as 0..9. Fixed by iterating the full 18 latitude rows,
matching the 18 longitude columns already used.

Extracted the pure grid/worked-field logic out of the @composable into
testable internal helpers (workedGridFields, buildGridHeatmapCells) per
the project's Compose-testing convention; the composable is now a thin
wrapper. Added GridSquareHeatmapTest (pure JVM) covering full 18x18
coverage, northern-hemisphere fields, and the field-extraction rules; 4
of its cases fail on the old rows=10 and pass after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Uppercase Maidenhead heatmap fields with Locale.ROOT

The coverage grid generates ASCII field designators (AA..RR), but
workedGridFields upper-cased each record's grid with the default locale.
Under a Turkish locale "io91" uppercases to "İO", which never matches the
generated "IO" cell, so those worked fields silently fail to highlight.
Use Locale.ROOT for the match, with a regression test under tr-TR.

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>
…eet (#578)

* Add "Last heard" relative-time line above map on QSO Details sheet

Show a human-readable recency indicator ("just now", "45s ago", "12m ago",
"3h ago", "2d ago") above the path map on the QSO Details bottom sheet, so
operators get immediate recency context without reading a raw timestamp.

The bucketing is extracted into a pure, unit-tested computeLastHeard()
returning a LastHeard sealed type (Compose-free, localizable), with a thin
LastHeardRow wrapper that maps buckets to localized strings. Edge cases are
handled cleanly: missing/non-positive timestamp -> "--", future timestamp
(clock skew) clamps to "just now", very old -> plain day count. The value
refreshes ~1s via the app's existing MainViewModel.timerSec UTC heartbeat
while the sheet is open. Thresholds mirror the decode-row "ago" formatter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Scope the Last-heard 1 Hz tick to its own row; comment/test cleanups

Address PR review:
- Observe MainViewModel.timerSec inside LastHeardRow rather than at the top
  of QsoSheetContent. The sheet hosts QsoPathMap (redraws every recompose),
  so a sheet-level tick observation repainted the whole map every second;
  scoping it to the row limits per-second recomposition to that row.
- Clarify computeLastHeard's KDoc: only the bucket thresholds mirror the
  decode-row "ago" formatter; the wording ("just now" vs "now") is separate.
- Assert LastHeard.Days(1) literally instead of Days(2 - 1).

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>
)

* Fix cross-thread crash on the shared transmitMessages calling list

GeneralVariables.transmitMessages (the calling-UI "followed entries" list)
was a plain ArrayList mutated concurrently with no external lock from three
threads:

  * decode thread   — MainViewModel.findIncludedCallsigns appends matches then
                      trims the front via deleteArrayListMore (remove(0)), and
                      clearTransmittingMessage clears it every cycle;
  * TX-sequencer    — FT8TransmitSignal.doComplete reverse-scans it for the QSO
                      signal reports (size()-1 .. 0, get(i)), onBeforeTransmit
                      appends;
  * UI thread       — GridTrackerMainActivity / GridMarkerInfoWindow append.

Under that contention the ArrayList's backing array is corrupted and the
reverse size()-then-get(i) scan on the TX thread throws IndexOutOfBounds when
a concurrent remove(0)/clear shrinks the list mid-scan — an uncaught crash on
the TX-sequencer thread at QSO completion.

Root cause: an unsynchronised mutable collection shared across threads. Fix:

  * make transmitMessages a CopyOnWriteArrayList so every add/remove/clear is
    atomic (widen deleteArrayListMore + CallingListAdapter to List<Ft8Message>,
    both source-compatible; ft8Messages is untouched);
  * snapshot the list once in FT8TransmitSignal.doComplete before the two
    reverse index scans so size()/get() cannot race a concurrent shrink. The
    private copy preserves the exact most-recent-first semantics.

Happy path (single-threaded) is byte-identical.

Adds TransmitMessagesConcurrencyTest: the field is a CopyOnWriteArrayList, the
snapshot reverse scan survives 20k iterations of concurrent decode-thread
mutation, and a deterministic case showing a live size()/get() throws on a
shrunk list while a snapshot is immune. The first two fail pre-fix.

Verified: :app:testDebugUnitTest and :app:assembleDebug (4 ABIs) both green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Lock transmitMessages final; rename deleteArrayListMore -> trimToMessageCount

Address PR review:
- Make GeneralVariables.transmitMessages final. The cross-thread crash fix
  relies on it always being the CopyOnWriteArrayList; final blocks an
  accidental later reassignment to a non-thread-safe List. It is only ever
  mutated in place, never reassigned.
- Rename deleteArrayListMore -> trimToMessageCount (and all callers, tests,
  and javadoc/comment references). It now takes a List, so the old
  ArrayList-implying name was misleading.

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>
… the rig) (#580)

* Desktop: clamp TX output at unity gain to stop resample overshoot overdriving the rig

The desktop TX output stage folded the full-scale clamp into the gain step and
guarded it out whenever `gain == 1.0`:

    if gain != 1.0 {
        for s in data.iter_mut() { *s = (*s * gain).clamp(-1.0, 1.0); }
    }

The guard is inverted relative to need. When `gain < 1.0` the product is already
in range so the clamp is a no-op; at `gain == 1.0` (TX level 100%) the clamp is
the only thing taming post-resample overshoot — and that is exactly the branch
that was skipped.

FFT resampling (rubato `FftFixedIn`) of the full-scale GFSK waveform, which peaks
at exactly ±1.0, interpolates a few samples slightly past full scale — measured
≈1.049 for 12 kHz→48 kHz (85 of 49152 samples over 1.0). The f32 output path
hands samples to the driver verbatim (`f32::from_sample` is identity) with no
saturation net, unlike the i16 cast which saturates in Rust. So at unity gain
those >1.0 samples reach the soundcard unclamped and overdrive the SSB rig's
input past 0 dBFS → ALC overshoot / distortion / spurious emissions. Desktop
almost always resamples on TX (hardware rarely offers 12 kHz), and the default
`DEFAULT_TX_GAIN = 0.9` clips (masking the bug) — only a 100% TX level exposes
it.

Root cause: the clamp is safety-critical and must not depend on gain. Fix mirrors
Android, which clamps unconditionally on the output cast
(`FT8TransmitSignal.float2Short` / `floatToInt16NoPad`), kept separate from its
`applyVolume` gain step. Extracted `apply_gain_and_clip()` and call it
unconditionally; for in-range samples the result is byte-identical to the old
`gain < 1.0` path.

Tests: 4 pure `#[cfg(test)]` cases in output.rs — unity-gain overshoot is clamped,
gain scales-then-clamps, in-range unity gain is identity, and an end-to-end case
that resamples a full-scale tone (asserting the resampler really overshoots) then
verifies the output stage clamps it at gain 1.0. The two unity-gain cases fail on
the pre-fix guarded code, verified by reverting only the guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Don't hard-fail the resample test on rubato-specific overshoot

The end-to-end test asserted rubato's FftFixedIn *must* overshoot >1.0 for
this sine input — an implementation detail that a version/filter change could
flip, spuriously failing CI even though the safety property still holds (and
is already covered by the direct unity-gain clamp unit test). Relax it: always
assert nothing exceeds full scale after the output stage, and only when
overshoot is actually observed assert the clamp pinned it to exactly ±1.0.

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>
…t) (#581)

* iOS: retransmit courtesy 73 if it missed its TX slot (don't clobber it)

iOS QsoEngine.processRx wrapped the QSO up (returned to CQ / stopped)
whenever stage == .bye73, on the assumption that the intervening TX slot
always sent the queued courtesy "73". But scheduleTx can bail — an
encode failure, or the slot's parity/timing not lining up — leaving the
73 queued but unsent. The next processRx then clobbered it by returning
to CQ, so the answerer's courtesy 73 (and a manual Tx5 73) could be
skipped in exactly those missed-slot cases, and the app prematurely
started calling CQ over the top.

The desktop reference port already fixed this (qso.rs, PR #570 commit
8843c39): finish_qso() is gated on a bye73_sent flag that the scheduler
latches via notify_transmitted() only when the TX actually keys up. The
iOS port dropped that safeguard.

Mirror the desktop fix on iOS: add a bye73Sent flag + notifyTransmitted()
to QsoEngine, gate finishQso() on it, and reset the flag everywhere a
fresh 73 is queued (setStage(.bye73), the .rReport -> RR73 completion
path, resetQso). Until the 73 is actually transmitted, processRx keeps it
queued so the scheduler retransmits it next eligible slot — matching how
every other stage retries. notifyTransmitted() is a no-op off .bye73, so
in-progress sequences are unaffected. LiveEngine.scheduleTx now returns
whether it actually started TX and the scheduler calls notifyTransmitted()
on success (mirrors the desktop engine's maybe_transmit).

Tests (FT8AFKit, runs on Linux via swift test): the existing answerer
sequence now signals the transmit; added
testBye73NotFinishedUntilActuallyTransmitted (missed-slot stays armed, no
double-log, finishes only after notify) and testNotifyTransmittedIsNoOpOffBye73.
Both fail pre-fix, verified by reverting only the gate. Full Kit suite
131/131 green. The LiveEngine app-target wiring is Xcode-only and was not
Linux-build-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Only report TX as sent when playback actually keys audio

scheduleTx returned true whenever encode succeeded, but txPlayer.play can
still bail before keying any audio (invalid hardware output format, buffer
allocation, or conversion failure). That falsely latched the message as
transmitted — notifyTransmitted() would mark a courtesy 73 sent when nothing
went on the air.

Make TxPlayerService.play return whether it started playback, and gate
scheduleTx's return (and the conversation-log entry) on it. On a bail the QSO
stays armed and retransmits next slot instead of clobbering the 73.

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>
…#582)

* Bound the flat-ring draw walk so an odd-length ring can't overrun

The equirectangular land renderer (`MapScreen.drawWorldLand`), the azimuthal
land renderer (`MapScreen.drawAzimuthalLand`), and the POTA / QSO-path map
renderers (`PotaActivationMap.buildRingPath`, `QsoSheet.buildRingPath`) all
walked a flattened `[lon, lat, lon, lat, ...]` polygon ring with
`while (i < ring.size) { ...ring[i + 1]...; i += 2 }`. That reads one element
past the end of an odd-length ring, throwing ArrayIndexOutOfBoundsException on
the Compose draw thread (uncaught → whole-app crash). Two siblings in the same
codebase already avoid this by bounding to `ring.size / 2`
(`WorldOutlines.pointInRing`, `CarMapSurfaceRenderer.buildPaths`); the four draw
loops were the outliers.

Root cause: the pair walk trusted `ring.size` to be even. Today every ring comes
from `WorldOutlines.ringToFlat`, which allocates `FloatArray(n * 2)` (always
even), so the overrun is latent — but the four live draw paths have no guard
against a malformed/regenerated basemap ring, unlike their bounded siblings.

Fix: extract a single `forEachRingVertex` helper (next to the flat-ring format
docs and `pointInRing` in WorldOutlines) that walks exactly `ring.size / 2`
complete vertices, dropping a trailing unpaired coordinate, and skips rings with
fewer than 3 vertices (equivalent to the old `if (ring.size < 6) continue`). All
four draw loops now call it. `inline` keeps the per-vertex callback
allocation-free on the draw hot path. Byte-identical output for every
even-length ring.

Test: RingVertexWalkTest (pure JVM, JUnit4 + Truth) covers even/odd/short/empty
rings, the first-vertex flag, and a reproduction of the pre-fix unbounded walk
that asserts it overruns an odd ring while the bounded walk does not.

Verified: :app:testDebugUnitTest and :app:assembleDebug (4 ABIs) both green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Return false from forEachRingVertex for an empty ring at any minVertices

The KDoc promises the return value is "true when at least one vertex was
plotted". With minVertices <= 0, the `vertices < minVertices` guard let an
empty ring fall through and return true while plotting nothing, so a caller
would close() an empty path. Add an explicit `vertices == 0` guard and cover
it with minVertices=0 tests (empty -> false; one vertex -> true).

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>
…ash the handler (#584)

* Guard web-logger uriList[2] path-segment reads so a missing month segment can't crash the handler

The always-on web-logbook HTTP server (LogHttpServer.serve, started at app
launch) read the trailing path segment uriList[2] in three dispatch branches
without the `uriList.length >= 3` guard its sibling branches
(DELFOLLOW/DELQSL/DELQSLCALLSIGN) already use:

- SHOWQSL (`showQSLByMonth(uriList[2])`) sat OUTSIDE the serve try/catch, so a
  bare `GET /SHOWQSL` — which splits to `["", "SHOWQSL"]` (length 2) — threw an
  uncaught ArrayIndexOutOfBoundsException out of serve().
- DOWNQSL / DOWNQSLNOQSL built the Content-Disposition header from uriList[2]
  unconditionally even when the body branch had already fallen back to the
  default page for a missing segment, throwing inside the try and returning an
  opaque HTTP 500 instead of the intended default page.

Route every uriList[2] read through a new bounds-checked static helper
`uriSegment(uriList, index)` (returns null when the segment is absent), and the
download filename through `downloadFileName(month)` (falls back to log.adi).
Behaviour is unchanged for well-formed requests; malformed ones now return the
default page. Both helpers are pure and covered by LogHttpServerUriSegmentTest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Treat empty URI segments as absent; drop attachment header on missing month

Address PR review of the web-logbook request guards:
- uriSegment now returns null for a present-but-empty segment (e.g. the "" a
  double slash "/SHOWQSL//" splits to), so an empty month can't reach
  showQSLByMonth/downQSLByMonth where month.length() == 0 could match rows.
- DOWNQSL / DOWNQSLNOQSL only emit the text/plain + attachment response when a
  month is present; a missing month now returns the normal HTML page instead
  of a browser-download .adi whose body is actually HTML.
- Add a present-but-empty-segment test case.

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>
…p the reply (#586)

* Desktop: key queued TX at the slot boundary so fast decodes don't drop the reply

The desktop engine only reaches `maybe_transmit` (the sole steady-state TX
trigger) from two places: the operator command handlers and `handle_decoded`
when a decode batch arrives. There is no per-tick / per-slot-boundary call.

A slot's audio is handed to the decoder at DECODE_AT_MS (13.2 s) and the reply
it produces is meant to go out in the *next* slot. But on a normal/fast CPU the
decode batch comes back well before the 15 s boundary, so `handle_decoded`
advances the QSO and calls `maybe_transmit` mid-slot — where the window check
(`now % 15000 > TX_LATEST_MS`, 2260 ms) correctly refuses to start a 12.64 s
waveform that late. Nothing then re-attempts at the top of the next slot, so the
queued reply/CQ is silently dropped: the auto-sequence stalls and CQ never
retransmits. It only worked when decode latency happened to land the batch
inside the next slot's window (~1.8–4.0 s), which the design comment even
assumes ("returns results around the 15 s boundary"). Only the first
operator-tap transmission (the immediate command-handler path) worked.

Fix: add a per-tick boundary trigger in the run loop that keys a queued message
at the top of its slot. The keying rules `maybe_transmit` already enforced
(active QSO, locked-parity alternation, once-per-slot, fits-the-window) are
factored into a pure `tx_slot_eligible` shared by both paths. A new
`awaiting_decode` flag (set when a slot is handed to the worker, cleared when its
decodes return or capture stops) gates the boundary trigger via `boundary_tx_ready`
so it never keys a *stale* message ahead of a slow decode — in that case
`handle_decoded` still keys the fresh message on arrival, so weak CPUs are
unaffected. `maybe_transmit` stays idempotent per slot (the `txed_slot` guard),
so the extra call can't double-key.

Behaviour-identical for the operator-tap and slow-decode paths; the change only
adds the missing boundary keying for the fast-decode case.

Tests: pure-unit tests for `tx_slot_eligible` (active/parity/once/window) and
`boundary_tx_ready` (keys at slot top only after this slot's decode; blocks on a
pending decode, closed window, already-txed, wrong parity, inactive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Track pending decodes as a count; clarify the boundary-trigger doc

Address PR review of the desktop slot-boundary TX trigger:
- Replace the single `awaiting_decode` bool with a `pending_decodes` count.
  The worker returns exactly one batch per enqueued slot, so if it ever falls
  behind by more than one slot, the first returned batch must not clear the
  boundary gate while later slots are still pending. Increment on enqueue,
  saturating_sub on each returned batch, reset to 0 on StopDecode. The gate is
  now `awaiting_any_decode(pending_decodes)` (pending > 0), with a test walking
  the fall-behind sequence a bool would get wrong.
- Reword boundary_tx_ready's doc: it keys anywhere within the TX window
  (into_cycle_ms <= TX_LATEST_MS), not at a hard slot boundary — "top of slot"
  is only the typical case because the run loop ticks fast.

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>
Add a root-level SECURITY.md covering all app flavors (Android, desktop
for Windows/macOS/Linux, iOS). Documents private vulnerability reporting
via GitHub advisories with k1af@ft8af.app as fallback contact, plus
scope, supported platforms, and upstream coordination for the vendored
ft8_lib DSP core and bundled Hamlib.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… log (#596)

`QsoEngine.classify` checked the decoded message's `grid` field before the
RR73/RRR/73 sign-off switch. "RR73" is a valid 4-char Maidenhead-shaped token
(R,R in A..R + two digits), so the decoder copies it into `grid`
(`looksLikeGrid("RR73")` is true) — meaning a partner's standard final "RR73"
arrives at the engine with both `extra == "RR73"` and `grid == "RR73"`.
Checking grid first classified it as `Content.grid`, which falls through the
`.rReport` stage's `default: break`: the auto-sequencer never completed the
QSO, retransmitted our R-report forever, and never logged the contact — for
essentially every answered QSO, since RR73 is the most common FT8 sign-off.

Both reference ports already guard this: desktop `qso.rs` matches the sign-offs
before the grid check (with a comment describing this exact regression), and
Android `GeneralVariables` excludes "RR73" from its grid classification. The iOS
port was the divergent one; this reorders classify to match, without touching
`looksLikeGrid` (the map/distance code relies on its shape test).

Added `testRr73WithDecoderGridStillCompletesQso` (ported from desktop
`rr73_with_decoder_grid_still_completes_qso`), which feeds the RR73 slot with
`grid == "RR73"` exactly as the decoder emits it live; it fails pre-fix
("RR73 sign-off must complete the QSO") and passes after. A companion
assertion confirms a genuine 4-char grid reply still advances the CQ
initiator to send a report. FT8AFKit `swift test` 132/132 green on Linux.

Co-authored-by: Optio Agent <optio-agent@noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: hold exclusive audio focus during AudioTrack TX and Tune

The AudioTrack TX path plays through Android's shared mixer, so sounds
from other apps (touch clicks, notifications, media) routed to the same
output device were mixed into the audio feeding the rig and transmitted
on air, corrupting the FT8 waveform.

Add TxAudioFocus, which requests AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE for
the duration of each transmission (and the Tune carrier) and abandons it
on teardown. The system suppresses notification sounds and asks other
apps to pause while it is held. Focus is advisory, so a denied request
is log-only and TX always proceeds. The CAT and direct-USB (libusb)
paths never acquire focus; release in afterPlayAudio() is a no-op there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Register TX audio-focus listener on the main looper

acquire() is called from worker threads with no Looper (the TuneTone
`new Thread(...)` path). Pass an explicit main-looper Handler to the
AudioFocusRequest.Builder listener so the focus-change callback never binds
to the calling thread's missing Looper. Add a regression test that drives
acquire() from a raw looperless Thread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Remove Android Auto support to clear Play production rejection

Play rejected the Production release under "Auto App Quality Guidelines:
App doesn't perform as expected — does not load map and user location in
Android Auto Environment."

The app declared itself a NAVIGATION-category Android Auto app, but it is a
ham-radio QSO monitor, not a navigation app: the car map only renders once
the phone engine is running (MainViewModel.peekInstance()) and centers on the
operator's Maidenhead grid rather than GPS, so a reviewer on a fresh install
sees the "open the app on your phone" template and no user location. A
non-navigation app cannot meet NAVIGATION-category quality bars, and no
approved Android Auto category fits this app, so a compliance fix would keep
being rejected. This takes Play's alternative path: remove the Android Auto
manifest wiring so the app is no longer flagged as AA-enabled.

- AndroidManifest.xml: drop the two androidx.car.app.* permissions, the
  com.google.android.gms.car.application descriptor + androidx.car.app
  .minCarApiLevel meta-data, and the FT8AFCarAppService service.
- Delete res/xml/automotive_app_desc.xml (dead once the descriptor is gone).
- CarAppManifestWiringTest: flip from asserting AA is wired to a regression
  guard asserting it is unwired, so the descriptor/CarAppService can't be
  silently re-added and re-trigger the rejection.

The car/ Kotlin package and androidx.car.app:app dependency stay in-tree
(dead but compiling) so the feature can be revived; debug-only AAOS
scaffolding (src/debug CarAppActivity + DebugInjectReceiver) is untouched.

Verified via unit tests (incl. the new guard, run against the merged
manifest) and processReleaseMainManifest. A full APK link isn't possible in a
fresh worktree (hamlib prebuilt isn't in git; needs a one-time WSL build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Guard against null ApplicationInfo.metaData in the AA-unwired test

ApplicationInfo.metaData is null when the manifest declares no
application-level meta-data at all; the assertions dereferenced it directly
and would NPE, masking the real regression signal. Treat null as an empty
Bundle so the test fails only when the Android Auto keys are actually present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* iOS parity: pre-stage settings fields for logging, highlights, tune

Adds SettingsState fields + persistence for Cloudlog/QRZ-logbook/PSK-Reporter,
decode highlight toggles, continent filter, distance unit, and tune timeout,
ahead of the feature wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS parity batch 1: online logging + waterfall parity

- Cloudlog/Wavelog and QRZ.com logbook upload (pure request builders in
  FT8Engine + OnlineLogService, upload-on-log, catch-up sync, sync chips,
  LoggingSettings screen, test-connection actions)
- PSK Reporter reception reports (IPFIX datagram port of the Android sender,
  5-min per-call/band dedup, UDP flush)
- QsoRecord syncedCloudlog/syncedQrz flags with legacy-JSON back-compat
- Spectrum width wired end-to-end (parametric WaterfallAxis/RowBuilder,
  live application, ruler math mirroring 647b12e)
- UTC period timestamps drawn on the waterfall (WaterfallTimestampGate port)
- RX input level meter (AudioInputLevel port, info-bar meter)
- App icon (1024 opaque from play_store_icon.png)

Kit tests 209/209; simulator build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS parity batch 2: TX/sequencer behavior + decode list parity

- Auto-sequence toggles wired (autoCQAfterQSO->autoReturnToCq, real HUNT
  auto-answer, huntCallsCQ, autoCallFollow); earlyDecode documented inert
- TX watchdog + stop-after-N via pure TxSupervisor
- Late-start clip rule max(0, msIntoCycle - 2360) via TxTiming (with
  anti-modulo regression tests); txDelayMs honored, pttDelayMs inert on iOS
- TUNE latching carrier with countdown + anti-click ramps (TuneTone)
- Caller queue (CallerQueue policy + QUEUE row in ActiveQsoPanel)
- ActiveQsoPanel header states, LOG/clear actions, TX stage selector chips
- DecodeRow rebuilt to Android layout (accent bar, status pills with
  resolveQsoStatus priority, SNR bar, distance/age/DXCC line)
- DXCC/continent prefix tables + DecodeAnnotator + display formatters
- showOnlyCQ / dxOnly / continent filters wired; DecodeFilterSettings real

Kit tests 298/298; simulator build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS parity batch 3: POTA live data + Android typography

- POTA Hunt tab fetches live activator spots from api.pota.app (pull-to-
  refresh, 60s auto-refresh, FT8 filter, tap-to-hunt band switch)
- Activation QSO count derived from logbook records in the activation
  window (PotaQsoWindow port); history persisted to Documents JSON
- Per-park activation ADIF export (PotaAdifExporter field set, MY_SIG=POTA)
- Inter + Geist Mono bundled from the Android font set and adopted across
  all views (UI=Inter, data=Geist Mono, slashed zero); Info.plist migrated
  to an XcodeGen info block carrying UIAppFonts + existing keys

Kit tests 329/329; simulator build green; smoke-run verified in simulator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS parity: review fixes, audio device pickers, TX/RX engine resilience

Code-review fixes (all 10 verified findings):
- Supervisor stops (watchdog / stop-after-N) now disarm hunt so hunt-calls-CQ
  cannot silently restart a stopped TX run
- Late-start clip slack locked to the physical 2360 ms constant; the
  late-start tolerance setting is now the skip threshold it describes
- stop-after-N counts only transmissions actually handed to the player
- forceLog no longer double-logs during the courtesy-73 window
- Restored NSLocalNetworkUsageDescription lost in the Info.plist migration
- Waterfall overlays/tap-to-tune map against the drawn data span
  (waterfall.displayMaxHz), not the live setting
- PSK Reporter: no sends after opt-out, flush-on-stop, no stranded queue,
  datagrams capped at 1400 bytes including padding
- Spectrum width default 3500 Hz (matches the previous fixed span)

Audio device selection (parity with Android's input/output pickers):
- Input Device picker backed by AVAudioSession availableInputs, preference
  persisted by port name and re-applied on replug
- Output Device row with the system route picker (AVRoutePickerView)

TX/RX audio-engine resilience (found live-testing in the simulator):
- TxPlayerService restarts a stopped shared engine before keying (silent-TX)
- AudioCaptureService handles AVAudioEngineConfigurationChange by rebuilding
  the mic tap at the new hardware format and restarting (silent-RX)

Kit tests 350/350; simulator build green; CQ TX audio and live FT8 decode
verified end-to-end in the simulator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* iOS: persist preferred input across route changes; cache POTA date parser

Address PR review:
- AudioRouteController stored the preferred input port name so a route-change
  refresh re-applies the *current* preference, not the one start() was seeded
  with. Previously the observer captured start's parameter, so a live input
  change via selectInput was silently reverted on the next route change.
  selectInput now updates the stored value too; refresh() reads it.
- PotaSpots.ageSeconds reused a single configured DateFormatter instead of
  building one on every call. It runs once per rendered spot row and
  DateFormatter construction is expensive; a configured formatter is safe to
  share for parsing. Behavior is identical (covered by PotaSpotsTests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.83658% with 394 lines in your changes missing coverage. Please review.
✅ Project coverage is 36.52%. Comparing base (8a6b2a7) to head (2708783).
⚠️ Report is 18 commits behind head on staging.

Files with missing lines Patch % Lines
desktop/src-tauri/src/engine.rs 36.24% 190 Missing ⚠️
desktop/src-tauri/src/os_location.rs 31.48% 74 Missing ⚠️
desktop/src-tauri/src/main.rs 0.00% 70 Missing ⚠️
desktop/src-tauri/src/db.rs 90.99% 28 Missing ⚠️
desktop/src-tauri/src/udp/mod.rs 93.10% 20 Missing ⚠️
desktop/src-tauri/src/udp/codec.rs 98.80% 6 Missing ⚠️
desktop/src-tauri/src/audio/output.rs 94.87% 2 Missing ⚠️
desktop/src-tauri/src/dsp/hashtable.rs 96.77% 2 Missing ⚠️
desktop/src-tauri/src/qso.rs 98.03% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@              Coverage Diff               @@
##             staging     #602       +/-   ##
==============================================
+ Coverage      21.66%   36.52%   +14.85%     
- Complexity       149      197       +48     
==============================================
  Files            163      216       +53     
  Lines          20877    26801     +5924     
  Branches        3128     3282      +154     
==============================================
+ Hits            4523     9788     +5265     
- Misses         16167    16790      +623     
- Partials         187      223       +36     
Flag Coverage Δ
android 14.73% <ø> (+1.59%) ⬆️
desktop 62.46% <80.83%> (+22.60%) ⬆️
ios 96.49% <ø> (+0.10%) ⬆️
native 9.93% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
desktop/src-tauri/src/bands.rs 99.24% <100.00%> (+99.24%) ⬆️
desktop/src-tauri/src/build_support.rs 100.00% <100.00%> (ø)
desktop/src-tauri/src/dsp/decoder.rs 75.25% <100.00%> (+0.12%) ⬆️
desktop/src-tauri/src/dsp/encode.rs 83.33% <100.00%> (+8.82%) ⬆️
desktop/src-tauri/src/rig.rs 19.30% <100.00%> (+4.62%) ⬆️
desktop/src-tauri/src/util.rs 100.00% <100.00%> (ø)
...n/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt 2.32% <ø> (-0.03%) ⬇️
...main/kotlin/radio/ks3ckc/ft8af/FT8AFApplication.kt 100.00% <ø> (ø)
.../kotlin/radio/ks3ckc/ft8af/crash/CrashReporting.kt 31.03% <ø> (ø)
...adio/ks3ckc/ft8af/pskreporter/PskReporterSender.kt 66.78% <ø> (+1.11%) ⬆️
... and 26 more

... and 56 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

… merges notify (#603)

The merge-notify step posted the full commit body as the embed description
with no length bound. Discord caps an embed description at 4096 chars (title
at 256); a large squash-merge commit body (e.g. #591) overran that, so the
webhook returned HTTP 400 and `curl -f` failed the `notify` job. The build
itself was fine — only the notification broke, and it broke on exactly the
biggest, most-worth-announcing merges.

Add a jq `clamp(max)` helper and apply it to both title and description
(slicing to max-1 codepoints + an ellipsis), so oversized commits still
post a truncated embed instead of erroring.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@patrickrb
patrickrb merged commit 5d231a1 into staging Jul 20, 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.

2 participants