Conversation
…sers (#689) * Add a "New State" decode highlight + filter for Worked All States chasers Surfaces stations calling from a US state the operator hasn't logged yet — the core of the WAS (Worked All States) award, one of the most-chased US awards. The decode-time unworked-state flag (Ft8Message.fromNewState) was already computed alongside fromDxcc/fromCq (US-grid -> state, US-only table) but only ever consumed by the "new state" alert; the decode list never showed it and there was no way to filter on it. - New "New State" filter chip: one-tap "who's calling from a state I still need" view (base.filter { checkIsCQ() && fromNewState }), mirroring "New DXCC" / "New Zone". - New NEW_STATE status pill (teal), shown when highlightNewState is on. Priority sits between NEW_ZONE and NEW_GRID — WAS is far more chased than a bare new grid field, so a new state outranks a new grid but still yields to the rarer new CQ zone (and the headline new DXCC). - Settings -> Decode Highlights toggle (highlightNewState, persisted via writeConfig / loaded in DatabaseOpr). Defaults off (like New Grid): the flag is US-only, so leaving it on would flood non-US operators with pills. The filter chip is available to everyone regardless of the toggle. No decoder or transmit code paths change. Tests: NewStateTest (pill + priority vs NEW_ZONE/NEW_GRID), DecodeFilterTest (filter cases), DecodeScreenTest (empty-state copy). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * NewStateTest: drop unnecessary Robolectric runner (pure-JVM test) The Ft8Message 3-arg constructor only uppercases its fields and the resolveQsoStatus path reads plain GeneralVariables state — no Android framework is touched. Verified the 5 tests pass under the plain JVM runner; dropping Robolectric removes its per-class startup overhead. Co-Authored-By: Claude Opus 4.8 (1M context) <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>
… decoder gives no SNR (#691) * Fix DX/watchlist/new-DXCC/new-state alert body showing '-2147483648 dB' when SNR is unknown DxAlertNotifier.defaultBody appended msg.snr unconditionally. A decoded message can be valid yet carry no SNR — FT8SignalListener logs "SNR not set by decoder" and still adds the candidate to the decode list, and that list is what feeds processDecodes(). When such a station is a new DXCC/state, a CQ reply target, or on the watchlist, the notification body rendered the SNR_UNKNOWN sentinel (Integer.MIN_VALUE) as "-2147483648 dB". The sibling cqReplyBody already guards this exact case; mirror it in defaultBody so the SNR line is dropped when unknown. Made both helpers package-private static and added DxAlertBodyTest (pure JVM) covering the unknown/known-SNR and missing-grid permutations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * DxAlertBodyTest: fix misleading test name and slash-joined @link Javadoc Rename defaultBody_gridOnlyWhenSnrUnknownAndGridMissing -> _callsignOnlyWhenSnrUnknownAndGridMissing (it asserts callsign-only output, no grid), and rewrite the class Javadoc to link defaultBody and cqReplyBody in a normal sentence instead of '{@link}/{@link}'. Co-Authored-By: Claude Opus 4.8 (1M context) <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>
…#666) * Make Worked All States (WAS) award real, with the states still needed The Logbook's WAS award was a placeholder: AwardsTab derived its worked count from DXCC entities (dxccEntities * 50 / 340), so the number shown had nothing to do with which US states the operator had actually contacted. FT8AF already resolves a QSO's US state from its Maidenhead grid (UsStateLookup), so real WAS progress can be computed straight from the logbook. This adds a pure WorkedAllStates helper (workedStates / neededStates / neededStatesPreview, with DC excluded) and wires it in: - Stats tab gains a real Worked All States progress bar (x / 50). - Awards tab's WAS card now shows the true worked count and, below the bar, a capped preview of the states still needed -- the at-a-glance list a WAS chaser actually wants. Chasing WAS is one of the most popular FT8 activities for the large US operator base, so an honest, actionable WAS tracker is a noticeable win. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * WAS: normalize grid with Locale.ROOT before US-state lookup Uppercasing the grid with the default locale could mis-resolve under the Turkish locale (lower-case 'i' -> dotted 'İ'), missing the ASCII keys. Normalize at the lookup source so every caller is locale-safe, and add a Turkish-locale regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <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>
* Show a station's gray-line / sun status when you tap it Tapping a decoded station now shows a "Gray line" line in the QSO sheet: whether that station is in daylight, night, or on the gray line right now, plus a countdown to their next sunrise/sunset. The gray line — the moving band of sunrise/sunset sweeping the Earth — is where HF propagation is briefly enhanced, so DXers deliberately time calls to a station's sunrise/sunset. FT8AF already draws the terminator on the world map (#675); this answers the same question for the one station you're about to work, right where you decide whether to call ("sunset in 12m — call now"). Implementation reuses the map's NOAA solar math (subsolarPoint / solarElevationDeg). New pure helpers in ui/map/SolarStatus.kt: - solarSnapshot(lat, lon, utc): current elevation, day/night, on-gray-line, and the next horizon crossing (found by scanning forward and interpolating the zero crossing; null in polar day/night where the Sun never crosses). - grayLineDisplay(): maps a snapshot to resource-free display tokens. - formatSolarCountdown(): compact "1h 20m" / "45m" / "now". The QSO sheet stays a thin Composable wrapper (GrayLineRow) that maps the tokens to localized strings; it computes once on open (the state changes over minutes) and renders nothing when the station's grid is unknown. Covered by SolarStatusTest (day/night, next sunrise/sunset, gray-line band, polar midnight-sun / polar-night, countdown formatting, display tokens). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Gray line: render a grammatical, localized 'at sunrise/sunset' for the now case formatSolarCountdown returned the hardcoded 'now', which the QSO sheet interpolated into 'sunrise in %s' -> 'sunrise in now' (ungrammatical, and 'now' was a non-localizable Kotlin literal). It now returns an empty string to flag the <1-minute case, and the sheet picks a dedicated qso_grayline_ sunrise_now / _sunset_now resource. Tests updated for the empty-countdown flag plus a display-token case. Co-Authored-By: Claude Opus 4.8 (1M context) <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>
* feat: add Max 73 Sends option to cap RR73/73 repeats per QSO The no-reply caps could not bound how many RR73/73s actually go on the air: with Stop After set to N, RR73 repeats up to 2N unanswered cycles; any decode from the partner resets the no-reply counter (so a partner re-sending R+report keeps RR73 going indefinitely); and every received RR73 re-triggers a 73 reply with no cap at all. New Transmission setting "Max 73 Sends" (Auto/1-10, default Auto = classic behavior) counts actual order-4/5 transmissions per target and completes the QSO when the cap is hit — the contact is already logged at the RR73/73 stage. A capped station's leftover R+report/RR73 is gated out of the caller-pickup scans (same cycle and after) so it cannot restart the loop; a fresh CQ/grid from them still gets answered and lifts the gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Max 73 Sends: gate only continuation orders 3-4, not CQ/73 isCappedContinuation used msgOrder >= 3, which also matched a fresh CQ (checkFunOrder returns 6) and an exact 73 (order 5) from the capped station — so a capped station's new CQ was silently ignored instead of answered, contradicting the intended 'order 3-4' behavior. Restrict the gate to orders 3 and 4, correct the Javadoc (CQ is order 6, not below 3), and add regression assertions for orders 5 and 6. Also reword the setting description 'Most' -> 'Maximum'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…rlay (#693) The map already fetches PSK Reporter reception reports for the operator's own callsign and plots each receiver as a dot, but reading "how far is my signal actually getting out?" off a scattering of dots means panning and zooming. This adds a glanceable bottom card that answers it directly: - how many distinct stations heard us, - the furthest receiver (great-circle from our grid) + its callsign, - the receiver that copied us with the strongest SNR. The card appears only when the overlay is in the "Heard me" direction and no individual station is selected, so it never competes with the existing station-detail / filter sheets. It reuses the existing 5-minute PSK poll — no extra network traffic. The reduction is a pure, unit-tested helper (summarizeSignalReach): de-dupes reports by callsign (keeping the strongest), tolerates a missing operator grid (count + signal still shown, distance omitted), and skips blank callsigns. The composable is a thin renderer over it. Co-authored-by: Optio Agent <optio-agent@noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Add a Worked All Continents (WAC) award to the logbook WAC — a two-way contact with each of the six populated continents — is one of the oldest and most recognisable amateur-radio awards, yet the logbook tracked DXCC, CQ/ITU zones and grids but never continents. This adds real WAC tracking derived the same way the existing DXCC/zone stats are: each logged gridsquare is resolved through the DXCC lookup tables (grid -> DXCC entity -> continent). A new Stats-tab card shows the six continents as chips (worked ones highlighted, "N / 6" progress, a completion banner at 6/6), and the Awards tab gains a real WAC progress bar in place of nothing. - CountDbOpr.queryWorkedContinents(): synchronous grid->continent join, extracted so it is unit-testable against an in-memory DB; wrapped by a new getContinentCount() AsyncTask mirroring getDxcc(). - workedAllContinents(): pure Kotlin reducer turning raw continent codes into award progress (normalises case, dedupes, drops Antarctica/blanks/ junk so they can't inflate the total). - Tests: WorkedAllContinentsTest (pure JVM) and CountDbOprContinentTest (Robolectric + in-memory SQLite) covering the join and filtering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * WAC: dedupe worked-continents query with SELECT DISTINCT + try-with-resources Clearer intent than GROUP BY, and the try-with-resources Cursor is guaranteed to close even if iteration throws. Behavior is unchanged; covered by the existing CountDbOprContinentTest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Optio Agent <optio-agent@noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#701) Three problems found while diagnosing a logbook server that had silently rejected every QSO upload for three days. **Zero-length fields.** An optional value that was present-but-empty (a QSO where the other station never sent a grid) exported as `<gridsquare:0> `. Several ADIF importers treat a length-0 field as malformed and reject the whole record rather than reading it as "absent" — 39 of 384 records in a real export carried one. Empty now means omitted, which every parser agrees on. `mode` had its own null-only guard and so kept emitting `<mode:0>` even after the shared helper was fixed; the new test caught it. `comment` was emitted unconditionally because the `<eor>` terminator was glued onto it — they are separate now. **QSL_MANUAL is not an ADIF field.** The bare name is non-conformant; ADIF reserves `APP_<PROGRAMID>_` for program-specific data. We now write `APP_FT8AF_QSL_MANUAL`, and `QSLRecord` reads both names so files exported by older builds still round-trip with their confirmation flag intact. **Upload failures were invisible.** `uploadAdifToCloudlog` returned a bare boolean and the server's explanation went only to `Log.d`, so `debug.log` recorded `cloudlog=0 qrz=0 of 113` — indistinguishable from having nothing to upload. The reason now flows through `SyncResult` into the log line, which would have read: QsoAutoSync: done (app-start): cloudlog=0 qrz=0 of 113 cloudlogError=HTTP 400: ... column "tx_pwr" of relation "contacts" does not exist That is a 30-second diagnosis instead of a three-day silent backlog. Also collapses `DatabaseOpr.downQSLTable` — the built-in web logbook's ADIF download — onto `AdifRecord`. It was a line-for-line duplicate of `AdifRecord.build()` and had drifted: it still carried both bugs above long after the file-export path was fixed. One builder now, so the next formatting fix can't land in only one of them (-95 lines). Tests: zero-length omission across every optional field, the APP_ prefix and legacy-name import round-trip, and the failure-reason formatting (status + server body, newline collapsing, truncation, and never echoing the submitted ADIF into debug.log). 2881 pass. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ting (#702) * fix: schedule the late full-slot decode so it works in tandem with the early pass With deep decode + early decode both on, the subtract-and-redecode loop ran on the truncated early buffer with an 11.25s budget (after an unbounded first deep pass), so the late full-slot pass didn't start until right as the next slot's early decode began — its first analysis-gate contention then aborted the whole late candidate scan, dropping the high-DT signals the pass exists to recover, almost every cycle. Now the two buffers split the work instead of duplicating it: - The early buffer keeps the time-critical fast pass and the quick first deep pass (pre-key-up sequencer evidence) — unchanged. - The subtraction loop moves to the full-slot buffer (a strict superset of the early one), which becomes the slot's single deep engine and also recovers high-DT signals ~12s earlier than before. - The whole full-slot pass is bounded by an absolute deadline (next slot boundary + early window − 750ms safety, capped by the deep budget), so it finishes before the next slot's early decode starts; the analysis-gate abort becomes a backstop for overruns instead of the routine exit path. No behavior change when no late pass is scheduled (early decode off, or FT4/FT2): the early-buffer subtraction loop runs exactly as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: clarify latePassDeadlineMillis assumes record-time cycle timing Per Copilot review on #702: the deadline is computed from the slot's record-time ModeProfile snapshot, so it only equals the next slot's actual early-decode start while the cycle timing is unchanged. Document that a mid-slot rebuildTimer() (mode switch) moves the real boundary and that the AnalysisGate contention abort is the backstop in that case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…min-SNR floor) (#699) * feat: add Hunt options sheet with target priority and smart filters The HUNT button gets the same notch/long-press affordance as the CQ button, opening a Hunt options sheet: - Hunt priority (single-select): Latest (historical behavior, default), Strongest, Weakest, Farthest, POTA/SOTA activators first (unhunted parks rank highest), New DXCC first, New grid first. - Smart filters: Avoid pileups (prefer CQs no one else is answering this cycle; soft preference) and a Minimum-signal floor (Off/-10/-15/-20 dB; hard filter so Hunt never starts a QSO that's unlikely to complete). Engine: the hunt scan in FT8TransmitSignal previously answered the first qualifying CQ (most recent decode). It now collects all qualifying CQs and ranks them via HuntTargetSelector, a pure Kotlin selector driven by three new persisted settings (huntPriority, huntAvoidPileups, huntMinSnr). LATEST with no filters short-circuits to the old behavior with no extra per-cycle work. All existing eligibility filters (worked-before, POTA-only, directional-CQ respect, exclusions) are unchanged. UI: a non-default priority shows a short tag under the HUNT label (STRONG/WEAK/DX/POTA/DXCC/GRID), mirroring the CQ button's FREE/FD subtitle. Options apply immediately, mid-hunt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: make Hunt tie-breaking explicit instead of relying on sort stability Per Copilot review on #699: append an explicit HuntCandidate.index tie-breaker to every priority comparator and pick with minWithOrNull, so the freshest-decode tie-break is a contract of the comparator (index is unique => total order) rather than an artifact of sortedWith stability, and no ranked copy of the pool is allocated per decode cycle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Swap the TX message mid-cycle when a late decode advances the QSO With early decode on, the fast pass delivers ~13.5s into the slot and the auto-sequencer keys up ~0.45s into the next one. The late full-slot pass (#363) then delivers its recovered decodes 0-3.5s into that next slot — after key-up. When one of those is the partner's reply, the sequencer advances the over a few hundred ms too late and we spend the whole cycle re-sending the message we had already sent. Measured on a real POTA activation (2026-07-30, 126 transmissions): 45 late-pass deliveries landed 0-3.5s into the slot, i.e. after the ~0.45s key-up. In the clearest case the late pass advanced order 3 -> 2 for K5UUT nine milliseconds after key-up; that cycle went out as a repeat of "K5UUT K1AF R-10" instead of the RR73 that would have completed the QSO. Sometimes the race is won instead — W0PPA's arrived 43ms before key-up — so which one you get is decided by milliseconds. The swap is free inside the audio slack. The waveform occupies slotMillis - audioSlackMillis (FT8: 12.64s of a 15s slot), so a restart anywhere within the slack still plays the new message COMPLETE and ends on the boundary; the receiver just sees it at a slightly larger DT, which every FT8 decoder searches anyway. Past the slack the new message could not fit without clipping its leading Costas array, so we let the original over finish and pick the change up next cycle as before. Two properties the implementation depends on: - PTT is NOT dropped for the swap. requestTxRestart() reuses the STOP cancel machinery to stop the writers mid-buffer, but leaves isTransmitting set and never fires onAfterTransmit; afterPlayAudio() takes an early exit that releases only the audio. Dropping and re-raising PTT would add the rig's key-up delay mid-transmission — on some rigs enough to miss the slack window entirely. - The guard reserves RESTART_HEADROOM_MS for the swap itself. The decision is made on the decode thread but playback restarts on the TX worker, so a swap approved at the very edge of the slack would begin playing past it and clip its own leading Costas array — reintroducing the exact defect the feature exists to avoid. The restart check sits outside parseMessageToFunction's body so every path that moves functionOrder is covered (RR73 reply, completion, give-up), not just the ones we remembered to instrument. Free text is excluded: its content doesn't depend on functionOrder, so a swap would replay the identical message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review: PTT strand on STOP, and uninterruptible playback paths Two real defects found in review. 1. afterPlayAudio() took the swap early-exit on txRestartPending alone. A STOP or deactivation landing between the swap being queued and the writers unwinding would therefore skip onAfterTransmit and LEAVE THE RIG KEYED, and leave txRestartPending set so the worker replayed an over the operator had just cancelled. The exit is now conditional on isTransmitting as well; the stop path falls through, clears the flag, and runs the real end-of-over teardown. The replay loop re-checks isTransmitting alongside consumeTxRestart() to close the last window. 2. The NETWORK and CAT-audio branches of playFT8Signal() never observe txAudioCancelled -- they spin on isTransmitting for up to 13.1s/13.0s -- and requestTxRestart() deliberately leaves isTransmitting set. A swap requested on those paths would not interrupt anything: it would sit queued until the wait expired and then replay ~13s into the slot, where the clip math strips nearly the whole message. That is worse than not swapping, so playbackSupportsMidCycleRestart() now refuses up front and the sequencer picks the change up next cycle as before. CAT *control* with sound-card audio stays restartable -- the CAT branch is only taken when the connector reports supportTransmitOverCAT(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…em (#705) * Bound GPS clock discipline to corrections FT8 can survive, and log them GpsClockUpdater.applyFix() writes UtcTimer.delay from every GPS fix, on by default at a 5-minute cadence. That value moves the WHOLE cycle grid -- every decode window and every transmit key-up -- and its only guard was an absolute +/-1 hour bound. Two changes: - MAX_SANE_OFFSET_MS: 1 hour -> 60 s. A phone even a few seconds out cannot work FT8, so an hour-scale "correction" can only be a mock provider, a bogus fix, or a timezone confusion. 60 s still covers a genuinely unsynced clock; past that the operator has a clock to fix. - New step bound (MAX_OFFSET_STEP_MS, 500 ms): reject a fix that jumps the applied offset by more than half a second within a discipline run. The absolute bound cannot catch the failure that actually bites -- a single bad fix whose implied correction looks plausible but slides the grid off the air. Physics makes it cheap to detect: GPS time does not jump and a device clock drifts milliseconds between fixes minutes apart, so a multi-second STEP is bad data whatever its absolute value. Only a run's first fix is unconstrained, since that is the one legitimately correcting accumulated drift. Motivating data (2026-07-30 POTA activation, from debug.log): the app spent two stretches transmitting 5.06 s and 7.63 s off grid. Every over in them keyed up past the 2.36 s audio slack, so lateStartSkipMs clipped the leading Costas sync array out of 17 of 126 transmissions (13.5%) -- loud on the air, undecodable at the far end. Both offsets sit well inside the old absolute bound; only a step check refuses them. That GPS discipline caused those two stretches remains INFERRED, not proven: applyFix only ever logged to logcat, so the pulled debug.log could not show it. Hence the second half of this change -- every applied offset and every rejection now goes to debug.log with the prior offset and the bound that refused it, so the next activation settles it either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review: sample both clocks once in applyFix() SystemClock.elapsedRealtimeNanos() and System.currentTimeMillis() were each read twice -- once for the offset that gets logged, once for the offset that gets evaluated against the bounds -- so the logged "REJECTED fix offset=" was not guaranteed to be the number actually judged. That undermines the diagnostics this PR exists to add, and is most likely to diverge in exactly the situation being diagnosed: a clock being corrected underneath us. Both clocks are now sampled once and the same readings feed the evaluation, the log, and the last-sync timestamp posted to the UI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ond (#706) * Stop setOperationBand re-sending an unchanged dial to the rig ~1x/second From the 2026-07-30 POTA activation: setOperationBand() ran in a continuous ~1 Hz loop for the entire session, re-sending FA014074000; MD0C; NA00;SH0117; about 57 times a minute to a rig that was already on that exact frequency and mode -- rig.getFreq matched the target on every iteration. 20,124 occurrences across the pulled log, present in every POTA session in it, at the same rate during completely healthy stretches. New RetunePolicy makes the retune idempotent: a request is pushed only when it is a new dial, or the rig is not where we want it, or a 30 s reassert heartbeat is due. Ordering matters and is what the tests pin -- correctness beats the rate limit, so a genuine retune is never delayed and the operator can never be left transmitting on the old dial. Only a request redundant in BOTH senses (same target as the last push AND the rig already reporting it) is throttled. This is CONTAINMENT, not a root-cause fix, and the caller driving the loop is still unidentified. It is provably not the connect path (11 autoConnect attempts in the whole window, no connect/disconnect churn logged), not a band change (no bandSelect: lines), and not self-triggering via onFreqChanged (BaseRig.setFreq early-returns on an unchanged dial, and the dial never changed). Two independent ~1.05 s series interleave ~0.53 s apart, one always observing the rig connected and one always observing it disconnected -- which points at duplicated observers or two live view-model instances rather than one runaway timer, but that is inference. So the change also adds a rate-limited suppression log that names the caller via its stack frame: setOperationBand: suppressed 28 redundant retunes (freq=14074000 already set) caller=com.k1af.ft8af.Xyz.tick:42 The stack is only walked on the rate-limited log path, never per suppressed call. The next activation's debug.log should name the culprit so the real fix can follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review: reset the rate limit on connect, harden the clocks 1. The rate-limit state persisted across a reconnect. onConnected() posts setOperationBand() precisely because a reconnect "previously left the rig on whatever frequency it powered up on" -- but that push has the same dial as the last one and a cached baseRig.getFreq() that still matches, so a reconnect inside the 30s reassert window would have been suppressed and silently regressed the bug that retune was added to fix. onConnected() now calls resetRetuneRateLimit() so the push is treated as a first push. Deliberately NOT reset from setOperationBand()'s not-connected branch: in the ~1 Hz loop this rate limit exists to contain, half the calls observe the rig disconnected, so resetting there would re-arm the loop every other iteration and defeat the fix entirely. 2. shouldLogSuppression() relied on a 0 sentinel being far enough below an epoch nowMs to clear the interval by arithmetic, which would have delayed the first line if it were ever fed a monotonic clock. There is now an explicit NEVER_LOGGED sentinel. Both intervals are measured with System.currentTimeMillis(), which is not monotonic. A backwards OS time correction made the raw delta negative and wedged the caller -- retunes suppressed, or the suppression log silenced, until wall time caught up. elapsedSince() saturates on backwards time so both fail safe (one extra CAT write, one extra log line) instead of silently disabling themselves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…osition (#707) * Keep the QSO panel's RX history instead of re-deriving it each recomposition Reported: "sometimes the little QSO details pane would lose the rx messages I had received and it would resize smaller." The panel OWNED its TX rows -- synthTxLog, a remembered state list -- but DERIVED its RX/BUSY rows by filtering the shared decode list on every recomposition. That list is not stable storage: - trimToMessageCount() drops from the FRONT once it reaches MESSAGE_COUNT (3000). At the ~50 kept decodes/min seen on the 2026-07-30 activation that fills in about an hour, mid-session, with no user action. - the clear-decodes-every-cycle setting wipes it at each slot boundary. - clearDecodesAndTarget() empties it on a band change, and the Clear button empties it outright. Any of those retroactively erased conversation the operator had already read, while the TX rows stayed -- exactly the reported asymmetry. And MessageLog's LazyColumn is heightIn(max = 160.dp) with no minimum, so it sizes to its content: fewer rows literally shrank the box, and zero rows swapped in a fixed 40.dp placeholder. That is the resize. RX rows now accumulate per target into their own remembered list, folded forward by mergeRxLog() and reset on a genuine target change alongside synthTxLog. Duplicates are expected input rather than an error: the decode list is cumulative and the late full-slot pass re-delivers a slot's messages, so identity is (direction, utcTime, messageText) -- time included so a station repeating itself in a later cycle still gets its own row, matching how TX rows are logged per transmission. Growth is bounded at MAX_RX_LOG_ENTRIES newest. Also fixes the second path to the same symptom. buildQsoLog returns an empty list when displayCallsign is empty, and that value comes from a LiveData observeAsState the code already documents as briefly emitting null on tab switches (the #250 comment). #250 fixed this for the TX rows with a 500ms settle on synthTxTarget but left the conversation keyed on the raw value. The log now targets displayCallsign ?: synthTxTarget, so a flicker can no longer blank it; a real QSO end still clears both, one target change later, exactly as before. mergeRxLog returns the caller's own instance when it adds nothing, and the composable skips the snapshot write on referential equality -- so a cumulative snapshot that contributes no new rows costs no recomposition. Size alone would have been wrong: at the cap a merge can append and trim in one step, leaving the size identical and the content different. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review: let a known row's metadata be updated, not discarded mergeRxLog keyed on (direction, utcTime, messageText) and kept the FIRST instance of a key, silently dropping metadata carried by later duplicates. That is reachable, and by a sharper route than "the same message might arrive twice": FT8SignalListener.checkMessageSame mutates the STORED Ft8Message in place -- "prefer known SNR over unknown; when both are known, keep the higher" -- and then drops the duplicate. So ft8Messages holds one instance whose snr field improves over time, typically when the late full-slot pass re-decodes a message the fast pass only heard weakly. Keying snr out of identity meant the panel pinned whatever SNR it saw first, often none, for the rest of the QSO. Before this PR the panel re-derived from the live list each recomposition and picked the improvement up immediately, so this was a regression introduced by the accumulation. A repeat of a known key now replaces the stored row when it differs structurally, which mirrors upstream's own resolution (it only ever moves toward the better value). An identical repeat -- the common case, every cycle -- still short-circuits and returns the caller's own instance, so the no-recomposition contract is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ings worse) (#708) * Remove the GPS step bound and debounce the retune reset (both made things worse) Both regressions caught on the 2026-07-31 activation by the diagnostics #705 and #706 added, which is the one thing that went right. 1. Remove MAX_OFFSET_STEP_MS (#705). The reasoning -- GPS time does not jump, so a multi-second step is bad data -- is sound in isolation and wrong as a rule, because it cannot tell "this fix is bad" from "the baseline is bad". Paired with an unconstrained first fix it made the first fix of a run permanent: 16:11:31 applied offset -1331ms (was -5000ms, prior GPS=none) 16:23:53 REJECTED offset -2112ms (prior=-1331ms, maxStep=500ms) ... eleven consecutive rejections over an hour, all near -2200ms ... A cold fix 2.6s after startup set -1331ms and every later fix was refused for being ~870ms away from it. Cost, measured across the 17:23 boundary where GPS finally got through: decodes 5.9/cycle before against 8.7 after, and transmit timing scattered (14 of 110 overs keying up 5s+ into the slot, versus 21 of 23 tight afterwards). One outlier is noise; eleven agreeing fixes are the truth, and the rule could never act on that. Rejecting a correction is not the safe default it looks like -- a stale offset puts the grid off the air just as surely as a bad fix, and unlike a bad fix it never self-corrects. The absolute bound and the logging stay. 2. Debounce the retune rate-limit reset (#706). The suppression log named the runaway caller directly: caller=com.k1af.ft8af.MainViewModel$1$$ExternalSyntheticLambda0.run:0 which is the MainViewModel.this::setOperationBand posted from onConnected(). CableSerialPort fires that on every successful port open(), and the port was re-opening about once a second -- so the reset added in code review re-armed the limiter on every iteration of the loop it exists to contain. Every suppression line read "suppressed 1" and retunes rose to 73/min, above the 57/min measured before the rate limit existed. Resetting is still right for a genuinely new link, so it is now debounced: a burst of connects seconds apart is one flapping link and does not re-arm; a reconnect after a real outage does. This also corrects the record on #706: I ruled out the connect path on 7/30 because there were few autoConnect attempts, but onConnected() fires per port open() without a fresh autoConnect, so that proxy was meaningless. The port re-open storm is the real root cause and is still to be fixed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Stop the CAT reconnect storm: a port that opens is not a link that works Root cause of the ~1 Hz retune loop, the constant "serial.send: port not open!", and the "PTT: unkey still owed after link loss" retries. Two compounding defects in CableConnector: 1. handleSerialError() passed a hardcoded 0 as attemptsSoFar to CatReconnectPolicy.decide(). Since shouldAutoReconnect was `attemptsSoFar < MAX`, a transient error ALWAYS returned RECONNECT and the SURFACE branch was unreachable for anything non-fatal. The parameter exists to bound this and was never fed. 2. startAutoReconnect() treated cableSerialPort.connect() returning true -- the port merely OPENING -- as success, returned, and ended the burst. With a link that opened and immediately errored again, the next error started a FRESH burst at attempt 1, so the escalation (500ms/1s/2s/4s/8s) never got past its first step. Measured on the 2026-07-31 activation: 13,190 port opens in 88 minutes (2.5/s), inter-arrival pinned at 0.51-0.53s -- exactly BASE_BACKOFF_MS plus the open -- and zero "Lost connection" lines. The give-up path never fired once in 88 minutes of continuous reconnecting, which is what proves the budget was being reset by every open. The fix is that only elapsed time proves a link works. The burst counter now persists across opens and resets only after a connection has held for STABLE_CONNECTION_MS, so a flapping link walks up to the 8s ceiling in a few attempts instead of sitting at 500ms forever: ~20x less churn (2.5/s -> 0.125/s). Retry is now unbounded for transient errors, per operator preference. Giving up would strand them with no CAT until they noticed the retry chip, whereas the storm was at least landing commands intermittently. FATAL classifications (device gone, permission denied) still surface immediately -- those don't recover by retrying. This does NOT explain why the port dies right after opening. That is a genuine link fault -- cable, OTG adapter, RFI, driver -- and this change only stops the software turning it into a 2 Hz storm. Expect it to make the underlying instability more visible, not less. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review: atomic burst counter, and rename the stale constant 1. reconnectAttempt was a volatile int mutated with `++` on the CAT-Auto-Reconnect thread while handleSerialError() (serial IO thread) and connect() (UI thread) reset it. `++` is a read-modify-write and is not atomic under volatile, so a lost update would hold the counter down -- pinning the backoff near BASE_BACKOFF_MS and reviving the exact storm this PR exists to stop. Now an AtomicInteger, and handleSerialError() resets-and-snapshots as one logical step so the value handed to decide() is the one that call established. 2. MAX_AUTO_RECONNECT_ATTEMPTS no longer bounded anything -- transient errors retry indefinitely -- so the name was actively misleading. Renamed to BACKOFF_ESCALATION_ATTEMPTS, which is what it describes: the attempt at which backoff reaches MAX_BACKOFF_MS. A new test pins that relationship so the name cannot drift from the behaviour again. The rename surfaced three more docs carrying the same dead "budget" framing -- the class javadoc, the Action.RECONNECT/SURFACE constants, and decide()'s contract -- all corrected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Stop discarding fast-pass decodes that land after key-up
The app was not seeing half the stations calling it. Measured on the
2026-07-31 activation: of 66 cycles where someone addressed us
(replyToMe=true in debug.log), 34 -- 52% -- never reached the auto
sequencer at all. They decoded correctly and were thrown away, so the
operator kept calling CQ at people who were answering and had to pick
callers by hand.
MainViewModel.afterDecode gated the fast-pass parse on three conditions
and silently dropped the decode when any failed:
if (!isTransmitting && !isDeep && replyCost <= budget) {
parseMessageToFunction(messages);
}
Two of those drop live evidence. The decisive one is isTransmitting: a
fast decode is delivered about earlyDecodeMillis plus decode time into
the slot, so a ~2s decode lands a few hundred ms PAST the boundary --
and key-up happens within the first half second. Measured gap between
key-up and the following delivery: 55 deliveries landed 0-0.4s AFTER it.
From the log, four consecutive cycles of exactly this:
16:39:01.841 QSO: TX msg=[CQ POTA K1AF EM28]
16:39:02.101 DECODE: kept=14 replyToMe=true <- 0.26s late, dropped
16:39:31.842 QSO: TX msg=[CQ POTA K1AF EM28]
16:40:01.835 QSO: TX msg=[CQ POTA K1AF EM28]
16:40:31.840 QSO: TX msg=[CQ POTA K1AF EM28]
16:41:01.841 QSO: TX msg=[W3HH K1AF -16] <- only after the
operator stepped in
"enqueue caller" fired twice in the whole session against 66 cycles of
people calling.
Deep passes landing in that same window were ALREADY stashed and
replayed; the fast pass -- the one carrying the timely reply -- had no
such path. It does now, via the existing PendingSequencerDecodes (which
already ages and evicts). Replay runs through the evidence-only parse,
which still answers a station calling us: checkCQMeOrFollowCQMessage is
invoked ABOVE the evidenceOnly guard in parseMessageToFunctionInner.
Only absence-of-evidence decisions stay suppressed, correctly -- this
cycle's no-reply call was already made.
The over-budget branch is stashed too, for the same reason: too slow to
key up this cycle does not make the evidence worthless next cycle.
Both drops were also invisible in debug.log, which is why this survived
four PRs of chasing adjacent problems. Both now log.
Decision extracted to FastPassDisposition so it is unit-tested; its
contract is that there is no third outcome -- every delivery either
parses now or is stashed for replay, and nothing is dropped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Address Copilot review: sample TX state once, and de-duplicate the rationale
1. isTransmitting() was read twice -- once for the decision, once for the
branch that words the log line -- so a flip between them would have
produced a stash logged with the wrong reason. Given this whole class
of bug survived four PRs precisely because the drop was invisible in
debug.log, a diagnostic that can lie about why is not a small thing.
Sampled once into a local now, and the two stash branches collapsed
into one with the message selected from that sample.
2. The activation-specific narrative in the branch duplicated the
FastPassDisposition javadoc. Trimmed to a durable one-liner with the
detail left in the class.
The one non-obvious fact the inline comment carried is now stated in
FastPassDisposition instead of being lost: replay only answers callers
because parseMessageToFunctionInner calls checkCQMeOrFollowCQMessage
ABOVE its evidenceOnly guard, so moving that call below the guard would
silently disable this mechanism.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#711) * Stop commanding the rig a frequency it echoed back during a CAT desync Reported as "it takes a loooong time for the radio to change frequencies when I switch bands or modes". The app is not slow -- it dispatches the retune in 815ms. The delay is a fight afterwards: 19:54:02 bandSelect: band=10136000 <- operator taps 30m 19:54:03 serial.send: FA010136000; <- out in 815ms rig replies "?;" <- rejected 19:54:11 setting freq=10136000 (rig.getFreq=14239985) <- rig reports a value nobody asked for 19:54:29 setting freq=14239985 <- THE APP COMMANDS IT BACK 19:55:01 setting freq=10136000 (rig.getFreq=10136000) <- settles, ~59s, 4 taps 29 rig rejections that session, every one following an FA set-frequency. onFreqChanged wrote whatever the rig reported into GeneralVariables.band, which is also the value setOperationBand pushes out -- so an observation was promoted to a command, and the 30s reassert heartbeat then fought the operator's selection for as long as the bad reading survived. Split the two roles. GeneralVariables.commandedBandHz is the dial the app asserts, set by explicit choices (band picker, mode retune, config load) and by a rig report only while the CAT stream is healthy. band stays the observed value for display, logging and PSK. The trust rule is deliberately narrow, because a report we did not ask for is in general indistinguishable from the operator turning the VFO -- and fighting a manual tune would be its own bug. The one case identifiable from evidence is a report arriving while the rig is refusing our commands, so Yaesu39Rig sets a flag on an unparseable frame and CableSerialPort clears it on the next send. Known limit: this only covers desyncs that produce an unparseable frame. A rig that silently reports a wrong frequency with a well-formed FA reply is still adopted, and would still be commanded back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address review: time-window distrust, live dial re-read Copilot review, both valid: 1. The delayed Runnable used the dialHz captured 800ms earlier, where it previously read GeneralVariables.band at execution time. A band change inside that window would therefore send the OLD frequency first and briefly retune the rig away from the newest selection -- a spurious extra FA on a rig already rejecting them. It now re-reads the commanded dial at execution time. 2. rigRejectedSinceCommand was cleared BEFORE the write was attempted, so a throwing write cleared it without the re-syncing command ever going out. Self-review found a bigger hole in the same mechanism, which subsumes (2): the flag was cleared by ANY outgoing command, and the CAT liveness watchdog polls the rig every CAT_LIVENESS_TICK_MS (3s) with a frequency read. That unrelated poll could clear the flag between the rejection and the bad report, defeating the guard entirely -- and it depended on send ordering with a component that knows nothing about it. Replaced with a timestamp: GeneralVariables.rigRejectedAtMs, and reports within RigDialTarget.DESYNC_DISTRUST_MS of a rejection are not adopted as the commanded dial. Depends on nothing but the clock, so there is no clear-path to get wrong and the CableSerialPort change is dropped entirely. A backwards clock correction does not re-trust. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Hold key-up while this slot's fast decode is still running
Missing callers turned out to be load-dependent, which is backwards from
what an operator needs: a pileup is exactly when auto-answer matters
most. Measured across one morning's activation:
busy 09:15-09:45 quiet 09:45 on
decodes kept per cycle 14.2 6.3
deliveries landing after key-up 35 0
The fast pass is delivered ~earlyDecodeMillis + decode time into the
slot, and key-up fires ~0.45s into the next one, so the decode has under
two seconds. On a busy band it does not make it, delivery slips past the
boundary, and the sequencer has already committed. #709 stopped those
being discarded, but a stashed decode is replayed on the NEXT cycle -- so
a third of callers were still answered 15s late, which is what the
operator was working around by hand.
There is ~1.9s of unused headroom before the audio slack runs out. Spend
it, but only when there is something to wait for: FastDecodeGate marks a
fast pass in flight, and the cycle-timer callback waits for it before
keying up. On a quiet band the decode has already finished and the wait
returns immediately, so key-up timing is unchanged for most cycles.
A fixed delay would have been the wrong shape -- it would tax every
transmission to fix a load-dependent problem. That is why the earlier
"hold key-up ~1.2s" option was rejected in favour of #704's mid-cycle
restart; conditional on a decode actually running, the objection does not
apply.
The bound is the load-bearing part. keyUpHoldLimitMs reserves the
configured pttDelay plus KEYUP_HOLD_RESERVE_MS for waveform generation
and output setup, so a held start still begins inside the slack and clips
no leading Costas array -- the defect this codebase has already shipped
twice. When the reserves exceed the slack the limit floors at zero and
behaviour is exactly as today.
Two ordering details: the gate is marked in flight BEFORE the decode
thread starts (the transmitter can reach its check first and would
otherwise see an idle gate), and released after DELIVERY rather than
after decoding, in a finally, since the sequencer acts inside the
delivery callback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Self-review: stop a failed decode stranding the key-up gate forever
fastDecodeGate.begin() runs before the decode thread starts, but end()
was only in a finally around the delivery call, roughly thirty lines into
run(). Anything throwing before it -- JNI decoder init, pressFloatDecode,
runDecode, OOM -- left the gate in flight permanently, and every later
key-up would then wait out the full hold before transmitting. The bound
means it could not clip audio, but it would silently add ~1.7s to every
transmission for the rest of the session.
The thread body is now wrapped so end() always runs. The inner release
stays: it fires right after DELIVERY so the transmitter is unblocked as
early as possible rather than waiting for the deep passes, and end() is
idempotent (already covered by a test).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Address Copilot review on the key-up hold gate (PR #712)
begin()'s Javadoc claimed the decode thread calls it, but it is called by
the spawning thread on purpose -- marking in flight only once the decode
thread is scheduled would let the transmitter see an idle gate and key up
against a decode about to run. Documented the ownership so the next
change does not "fix" it by moving the call inside the thread.
end()'s doc now also mentions the finally backstop added in c8bc1cd and
that the two release paths rely on it being idempotent.
Docs plus one stray indent from c8bc1cd; no behavior change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…710) * Add RTOTA trip mode: live GPS route + QSOs to rtota.app Roving operators can now record a road trip from the app and have it appear live on rtota.app instead of waiting for an end-of-trip ADIF upload. Settings -> Road Trips (RTOTA) registers the callsign (or takes a pasted API key), starts and ends a trip, shows what has reached the server, and announces a planned trip to followers. While a trip runs, a location-typed foreground service owns the GPS subscription so breadcrumbs keep coming with the screen off, and every QSO written to the log is queued for the same trip (bulk ADIF imports excluded -- the hook rides the existing appendToAdifFile gate). Built for the failure mode roving actually has: no coverage. * Everything recorded lands in an on-disk queue before any network attempt, so a canyon, a reboot, or an OS kill costs time and nothing else. * A trip can be started AND ended with no signal at all; creation and completion are deferred flags the flush loop resolves later. * Flushes are single-flight, batched, backed off to 15 minutes, and triggered immediately when a validated network appears. * TripPointSampler cuts a 1 Hz fix stream down to the shape of the route (time floor, distance floor, plus a point through a turn) and stays silent while parked, which is what lets the server derive overnight stops. The API key lives in Keystore-backed EncryptedSharedPreferences rather than the config table, which the settings-backup export copies verbatim. 58 unit tests cover the sampler, the queue (including a killed-mid-write file), the wire payloads against the server's zod schemas, QSO mapping, and the client's request shape + retry classification via MockWebServer. * Sample the route with SmartBeaconing and pin QSOs to the path Replaces the fixed interval/distance sampler with SmartBeaconing (TM) - the HamHUD scheme APRSdroid, the Kenwood D710 and most APRS trackers use - so the recorded breadcrumbs actually draw the road. Rate follows speed: below the slow threshold beacon rarely, above the fast one beacon at the fast rate, and in between fastRate x fastSpeed / speed, which holds the spacing of points roughly constant instead of the time. Corner pegging sends a point as soon as the course changes by more than minTurnAngle + turnSlope/speed. That division is the whole trick: at 65 mph the threshold is ~19 degrees (an interstate curve), at 25 mph it is ~25, and at walking pace it is effectively unreachable. Measured on a replayed 8.5-mile drive with a 30 s sweeping curve (SmartBeaconRouteFidelityTest): worst deviation of the real path from the drawn polyline is 29 m with corner pegging and 120 m with interval-only sampling, a curve cut across four times wider than the highway itself. Two departures from stock SmartBeaconing, both documented in the profile: * A corner also requires real movement (turnMinDistanceM). APRS trackers read course from GPS velocity, which is undefined when stopped; Android keeps reporting a bearing, so a phone idling at a light would otherwise grow a scribble of points where the truck never moved. A test drives exactly that. * A true standstill emits nothing at all. APRS keeps beaconing to stay visible; RTOTA derives overnight stops from 4h+ gaps, so silence while parked is the signal. Departure is beaconed immediately (RESUME) so the stop is bounded. QSOs now plot on the line rather than beside it. Contacts are stamped from the freshest fix instead of the last beacon (which on an interstate can be half a mile back), and that position is forced into the route as a QSO-anchored vertex, deduped when a point is already within 20 s and 25 m. Ending a trip anchors the final position the same way, so the line stops where the rover did. Profiles (Car / Bicycle / Walking) replace the raw interval and distance rows; the screen prints what the chosen profile will actually do, and the trip card and notification now show which rule kept the last point (corner, contact, parked) so the behaviour is legible from the passenger seat. 75 RTOTA tests: rate curve, turn threshold, speed fallback, corner pegging and its guards, parked silence over an 8 h stop, resume, QSO anchoring, plus the route-fidelity replay above. * Drop the bicycle and walking beacon profiles RTOTA is a road-trip service: the rover is in a vehicle. The other two profiles were speculative, and a picker with one sensible answer is a setting the user has to think about for nothing. SmartBeaconProfile keeps its parameters (they are still worth naming and documenting in one place, and the fidelity test builds variants with copy() to isolate a single rule) but loses the key/ALL/byKey machinery, the stored preference, the mid-trip setter, and the tap-to-cycle row. The tracking section is now a read-only line stating what the sampler does -- still worth saying, since a trail that goes quiet at a fuel stop otherwise reads as broken. 72 RTOTA tests still pass; the route-fidelity numbers are unchanged. * Make trip mode transmittable, deliverable, and locatable Five things stood between RTOTA trip mode and a real drive. **The base URL could never have worked.** DEFAULT_BASE_URL was the apex https://rtota.app, which 308-redirects to www. No HTTP client may follow a 308 for a POST (RFC 9110: the method and body have to survive, so clients decline rather than guess), and every write here is a POST — so trip creation failed permanently, non-retryably, with the whole queue stranded behind it. normalizeRtotaBaseUrl repairs the host on read as well as write, so an install that already persisted the apex heals on upgrade instead of 308-ing forever. **"CQ RTOTA" is not encodable, and was not wired at all.** Nothing in the package ever touched GeneralVariables.toModifier. And the token itself has no encoding: an FT8 standard message packs the CQ into the 28-bit c28 field, whose vocabulary is CQ plus one to four letters. POTA fits at exactly four; RTOTA is five, and Ft8Message drops an over-long modifier silently — you would transmit a bare CQ all day and only find out afterwards. RtotaCqSession imposes RTOA for the duration of a trip and hands the modifier back on the way out. It composes with POTA's own save/restore: a park activation started mid-trip banks RTOA and returns it when it ends, and ending the trip while POTA holds the modifier leaves it alone rather than clobbering an active activation. **Resumed trips re-sent everything.** The service grew a sync-state handshake; a trip resumed from disk now asks what the server already holds and prunes acknowledged contacts by exact dedupe key, turning "re-send the whole day" into "send the last few minutes". Matched on the service's exact key format, because a near-miss merely re-sends a QSO that dedupes anyway while a false match would discard one that never arrived. **Breadcrumbs never carried a highway.** onLocationFix set state but never highway, so the service's highwaysTraveled roll-up was always empty. HighwayResolver names the road from a cache with a refresh policy — it never blocks the location callback, throttles on time *and* distance, and expires a label so it can't be carried across a dead zone. An unresolved fix stays null: "not a highway" is a fact, "we don't know" is not, and conflating them would report local roads across a canyon. **QSOs had no position outside trip mode.** Every real-time contact is now stamped with the operator's coordinates, trip or no trip, and the ADIF carries them as standard MY_LAT/MY_LON plus exact decimal APP_RTOTA_ twins. Only real observations qualify — there is deliberately no tier derived from the configured grid, whose centre would dress a ~55 km square up as a measurement. No fix means no coordinates, and rtota.app places the contact from the breadcrumb trail instead. Verified against the live service: register, create, live POST, an identical re-send that deduped to zero, sync-state, complete. The service's own zod schemas and dedupeKey() accept the app's payloads, and its ADIF parser puts the rover back at the exact coordinate written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review on trip-mode permissions and locale (PR #710) **Approximate location no longer blocks Start trip.** The screen gated on ACCESS_FINE_LOCATION while RtotaLocationTracker runs on fine *or* coarse, so answering "Approximate" to the Android 12+ dialog — an ordinary choice — read as a refusal and re-prompted for a permission already granted. There were three copies of this check drifting apart (screen, tracker, RoverPosition); they now share one definition, so the class of bug is gone rather than the instance. **The notification can now say "parked".** The parked transition happens on the not-a-beacon path, which updated state without publishing — and once parked no fix is kept, so recordPoint's publish() never ran either. The notification claimed the rover was still rolling for as long as it sat there. Republished on the transition only: updateNotification does no throttling of its own, and non-beacon fixes arrive about once a second, so publishing every one would rebuild the notification all trip. **Callsigns upper-case in Locale.US.** The bare uppercase() is locale-sensitive for ASCII: under Turkish or Azeri, "i" becomes "İ" (U+0130), so a phone in that locale would store and register a callsign the server can never match — and disagree with RtotaClient, which already normalized with Locale.US. Swept the rest of the package; this was the only bare conversion left. **Fixed a misleading test doc.** syntheticDrive's KDoc called the middle segment a right-hand curve; the heading runs 90° to 0°, which is a left turn, as its own inline comment said. Regression tests for the two testable fixes: the permission predicate under each grant combination (Robolectric), and callsign normalization under Turkish, Azeri, German and US locales. The notification fix is a side effect on a foreground service and isn't reachable without a service harness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Show DT on every decode, as WSJT-X does The slot bar has carried a clock-sync pill for a while, but it shows the *mean* DT across the cycle — and a mean cannot answer the question an operator actually has when it reads badly. "Every station I hear is at -1.3" means my clock is wrong; "one station is at -1.3" means his is. Those need the same fix in opposite places, and until now the app gave no way to tell them apart. The value was already there and already trusted: `Ft8Message.time_sec` is the per-decode offset, and `mutableTimerOffset.postValue(time_sec)` is what feeds the existing pill and the correction suggestion in Time Sync. This just puts it on the row it belongs to. Rendered WSJT-X style — signed, one decimal, no unit — and prefixed "DT" rather than suffixed with seconds, because the metadata row already ends in an "ago" time and a bare "-1.3 s" beside it reads as another duration. Values that round to zero render unsigned: "-0.0" is noise in a column being scanned for a sign. Amber past ±1.0 s, which is deliberately the same threshold the slot bar's pill calls the edge of "fair" — a row must not shout while the averaged indicator above it is still calm. Verified by unit test and installed on a device; the label itself is not visually confirmed, since showing it needs live signals and no radio was attached. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review on DT visibility and threshold (PR #713) Both valid. **Narrowed to internal.** `formatDecodeDt` and `isDecodeDtNotable` have no callers outside the decode UI and its tests, and `internal` is what the neighbouring ClockSync helpers already use. Tests live in the same module, so nothing needed relaxing to keep them compiling. **Stopped repeating the threshold.** `isDecodeDtNotable` hard-coded 1.0f while its own KDoc claimed it matched `CLOCK_SYNC_FAIR_SEC` — true only by coincidence, and silently false the moment either one moved. It now shares the constant with the slot bar's pill, which is the property that actually matters: a row must never call a reading alarming while the averaged indicator above it still calls it fair. The test asserted the same literal, so it would have sailed straight through that drift. It now asserts against the constant and its neighbours instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Name a trip from the plans already saved on rtota.app Tapping "Trip name" opened an empty text box, so a trip planned on the site had to be re-typed from memory — and a name that doesn't match is a trip nobody can line up with the plan that announced it. The list is of *scheduled activations*, which is what the site's plan wizard actually writes: a trip only exists once someone drives it. Read from /api/me rather than the public /api/activations, because the public listing carries only what a stranger may see, and a plan marked private or followers — the ones most likely to be a real upcoming trip — would be missing from exactly the list the operator is trying to pick from. Picking binds nothing. The server decides which plan a trip fulfils by comparing start times with twelve hours of slack either side, so the value here is that the name matches and that the operator can see, before setting off, whether starting now will inherit the privacy they chose in the wizard. Plans outside that window are still listed — driving early is normal — but say so, because the consequence is otherwise silent: a plan marked `delayed` whose privacy doesn't apply publishes a live position that was meant to lag, and nothing on screen would have mentioned it. The window check mirrors MATCH_SLACK_HOURS in the service's lib/activation-match.ts, including its assumption that an open-ended plan spans a day. It is advisory only; the server remains the decider. With no API key the row goes straight to the free-text box as before — the plans live behind that key, and an empty picker with an auth error in it explains nothing. Verified against the live service: all three of the account's plans listed, the in-window one marked, and picking it set the trip name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review on the plan picker (PR #714) All three valid. **A failed plan fetch no longer reports itself as the trip's problem.** The picker reused `rtota_error`, whose text is "Last error: …" — phrasing that belongs to the trip's own upload failures. An operator whose plans failed to load would have read it as the running trip being in trouble. Now has its own string that says what actually happened. **The plan list scrolls.** It was a bare forEach in a Dialog's Column, so a rover with a season of plans would have rows running off the bottom of the screen with no way to reach them — and on a picker, unreachable means unselectable. Height-capped and scrollable rather than a LazyColumn, so a short list still hugs its content instead of always claiming the cap. **The slack test now pins the boundary.** It was named for a twelve-hour rule while asserting eleven hours in and thirteen out, which would have passed just as happily against an eleven- or thirteen-hour rule — the constant was never actually tested. Now asserts the inclusive edge to the millisecond either side, plus the constant itself. The open-ended-plan test had the same weakness (37 h, where the rule is 36) and got the same treatment, though Copilot only flagged the first. Unit tests pass. The picker was verified on-device before these changes; the layout restructure is NOT visually re-confirmed, because the phone locked and needs biometric auth I can't supply. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…crash) (#715) * Bump the schema version so my_lat/my_lon actually get added (fixes a crash) Every logged QSO crashed the app for anyone who upgraded rather than installed clean: SQLiteException: table QSLTable has no column named my_lat at DatabaseOpr.doInsertQSLData(DatabaseOpr.java:1565) The position columns were added in two places — the CREATE TABLE and the alterTable block — but the schema version was left at 19. Those ALTERs only ever run from onCreate or onUpgrade, and onUpgrade fires solely when that number increases, so on an existing database they never executed while the INSERT went on naming the columns regardless. The shape of the bug is why nothing caught it: a fresh install takes the CREATE TABLE path and works perfectly, so it is invisible in development and in any test that starts from an empty database. It only appears on a device that already had a logbook — which is every real user, and nobody running the tests. Bumped to 20 and gave the constant a name and a comment, since the failure mode is not obvious from the call site. The regression test builds a v19-era QSLTable by hand, stamps it with the old version, lets DatabaseOpr open it, and asserts the columns arrive — the upgrade path, not the create path, because the create path passed throughout the bug. A third case asserts every column doInsertQSLData names is present after upgrade, so the next column added without a migration fails here rather than in a car. Verified by reverting the constant to 19: all three tests fail. Restored to 20: all pass, and on the affected device the database upgraded in place to version 20 with both columns present and all 564 existing QSOs intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address Copilot review on the upgrade test (PR #715) Both valid, both in the test rather than the fix. **The legacy database is now closed in a finally.** It was closed after an assertion that can throw, so a failing assertion leaked the handle — and on Windows an open handle keeps the file locked, which would then defeat the delete() each test does on the way out and leave the next test opening a database it believed it had created fresh. A flaky suite is a poor way to learn that. Pulled the setup into writeLegacyDatabase() rather than wrapping both copies: the second test had the same exposure through execSQL, and one helper removes the duplication and the leak together. It also means the 'the legacy table really lacks my_lat' precondition now guards both tests, where before only the first checked it. **columnsOf quotes the identifier and uses getColumnIndexOrThrow.** PRAGMA takes no bind parameters so the name has to be inlined, but it can at least be quoted. The index change is the more useful half: getColumnIndex returning -1 surfaces as getString(-1) failing with an opaque index error several frames from the cause. Re-verified the property the test exists for after restructuring it: reverting SCHEMA_VERSION to 19 fails all three, restoring 20 passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The program is now "Roads On The Air (ROTA)" at roadsontheair.com. This renames the Kotlin package, its classes, the string resources and all user-facing copy to match. Three things deliberately keep the old spelling, because they are state that already exists on someone's phone or in someone's files: - The EncryptedSharedPreferences filename stays "rtota_prefs". Renaming it would orphan every install's API key and, worse, its in-flight trip id and queued breadcrumbs. - ADIF now emits APP_ROTA_LAT/LON but still parses APP_RTOTA_LAT/LON on import, so archived exports and logs from older builds keep their exact rover coordinates instead of falling back to the rounded MY_LAT/MY_LON. - normalizeRotaBaseUrl() now rewrites a stored rtota.app origin to the new domain, so an install configured before the rename keeps uploading instead of failing against a host we no longer serve. Two changes go beyond a search-and-replace: The on-air CQ token becomes ROTA. It was the anagram RTOA only because RTOTA is five letters and an FT8 standard message encodes a CQ modifier of at most four; ROTA fits exactly, like POTA, so the workaround is retired and the token is finally the program's own name. maskKey() now cuts the API key at the prefix separator instead of a hardcoded six characters, which was exactly the width of "rtota_". Keys minted as "rota_" are a character shorter, and the fixed width would have printed a character of the secret itself on screen. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Start the announced trip instead of creating one beside it roadsontheair.com folded announcements into the trips table as a `planned` status, and deleted /api/activations. Three things follow. The announce call moves to `POST /api/trips` with `status: "planned"` and `name` (was `title`). The old path is a 404 now. `parseMyPlannedTrips` reads `plannedTrips[].name` off /api/me, was `activations[].title`. This one failed *silently*: the parser turns an unrecognized shape into an empty list, so the picker rendered "no upcoming trips" rather than an error. The keys are pinned in a test for that reason, including one asserting the old shape yields nothing. Picking a plan now binds. The plan is the same row the trip will be driven as, so Start promotes it by id (`POST /api/trips/:id/start`) rather than creating a second trip and leaving the announcement at `planned` forever. The wizard's privacy — delay, route trim, replay lock — therefore applies by construction. It used to be name-only, with the server guessing which plan a trip fulfilled from departure times within ±12 h; driving outside that window silently fell back to the account default. `activationMatchesNow` and the picker's window warning mirrored that guess and are gone. Promotion respects the deferred-create design, so a rover can still pull out of a driveway with no signal: the flush loop starts the plan when the network allows. Two failures are rover-normal rather than errors — 409 (a retry that actually landed, or another device) adopts the row, and 404 (the plan was cancelled on the site while out of coverage) falls back to a plain trip so the drive isn't stranded behind a queue that never drains. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Ask the server what an adopted trip holds (PR #717 Copilot review) The 409 branch said it would "let the resume handshake below establish what the server holds", but never armed it. `resumeHandshakePending` is only set in `restore()`, and set to `tripId.isNotEmpty()` — a trip still at `tripPendingCreate` has an empty id, so it is false there too, and `startTrip()` sets it false outright. Every path that reaches the 409 is therefore a path that skips the handshake. Adoption is exactly the case the handshake exists for. Its own doc draws the line at "did this process start the row" — and on a 409 it did not: another device started the plan, or a start whose answer we never saw landed. That row's contents are as unknown as one resumed from disk, so the queue should not be shipped without asking. The GET is cheap and already best-effort, so a failure still doesn't touch the backoff. The decision goes in a named function next to the outcome it reads from, with a test pinning the set of outcomes that arm it, so a fourth outcome has to answer the question rather than inherit "no handshake". Also fixes the KDoc on startPlannedTrip: the property is `httpCode`, not `code`, so the link didn't resolve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…0) (#668) * Show real VUCC grid-square progress on the Awards tab (was hardcoded 0) The Awards tab's VUCC (grid squares) card was hardcoded to current = 0, so operators chasing the grid-square award always saw "0 / 100" no matter how many unique grids they had logged. The Stats tab already computed the real count; the Awards tab just never received it. Extract the grid-square counter into a pure, testable internal gridSquaresWorked(grids: List<String?>) (upper-casing with Locale.ROOT so squares de-dupe correctly under a Turkish locale), surface the count through LogbookStats.gridSquares, and use it for both the Stats-tab bar and the Awards-tab card so the two views agree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Read the VUCC bar from stats, not a second computation (PR #668) The Stats tab's four award bars all read stats.*, except the VUCC one, which recomputed gridSquaresWorked(records) inline. Both derive from the same loaded list, so the number matched — but `records` is assigned as soon as the query returns, while `stats` is only built after the DXCC, zone, continent and state lookups finish. In that window the VUCC bar showed a real count beside four bars still reading their defaults. Reading stats.gridSquares makes the row update as a unit, drops a full pass over the log on every recomposition, and leaves one place computing the value. Also fixes the grammar of the test file's header comment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Optio Agent <optio-agent@noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sharing a POTA activation that has no QSOs crashed the app. The exporter runs on Dispatchers.IO, and the empty-documents exit reported failure by calling the caller's callback right there on the worker. The POTA screen's callback shows a Toast, and Toast.makeText needs a Looper, so it threw "Can't toast on a thread that has not called Looper.prepare()". The empty case is just the most reachable of four exits: the missing external-files-dir and the catch-all both did the same, and only the null-database early return — which never leaves the caller's thread — was safe. So the fix belongs in the exporter, not at the one Toast: a caller cannot see which thread its callback arrives on, and fixing the symptom would leave the next caller to rediscover this. Every callback now goes through deliverOnMain, which runs inline when already on the main thread and posts to the main Looper otherwise. That keeps the null-database path synchronous, as it was. startActivity stays on the IO thread — FLAG_ACTIVITY_NEW_TASK makes that legal, and only the callback had a main-thread requirement. Fixes #700 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#695) * Add a "New Prefix" (WPX) decode highlight + filter for prefix chasers Worked All Prefixes (CQ WPX) is one of the most-chased amateur-radio award programs, but until now the decode list could flag new DXCC entities, zones, states, grids and bands — not new callsign prefixes. This adds a "New Prefix" highlight pill and decode filter that mark CQ stations whose WPX prefix (e.g. W1, VE3, DL0) the operator hasn't logged yet, so prefix hunters can spot a new one at a glance and one-tap-filter the list down to only new prefixes. - WpxPrefix.of() is a pure, dependency-free CQ WPX prefix extractor (simple calls, no-numeral historic calls, portable numbers CALL/n, portable prefixes pfx/CALL, ignored /P /M /QRP suffixes; non-callsigns return null). Shared by the DB worked-prefix loader and the live decode predicate so they can't drift. - GetAllQSLCallsign builds a distinct worked-prefix set (any band), mirroring the existing worked-grid set. - New NEW_PREFIX status pill, isNewPrefixStation predicate, "New Prefix" filter chip + empty state, and a Settings → Decode Highlights toggle (off by default, like New Grid — early on most prefixes are "new"). Tests: WpxPrefixTest covers the extractor across simple/compound/edge cases; NewPrefixTest covers the predicate, the pill priority, and the filter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Derive the portable-number prefix from the base call's own prefix (PR #695) CALL/n lifted the leading letters off the raw token and appended the new number. That works only for calls whose prefix starts with letters and carries a numeral, which is what the tests covered. Two shapes it got wrong. A digit-leading call has no leading letters at all, so 9A1AA/7 lifted "" and returned null — a prefix chaser simply never saw it. A historic call with no numeral is all letters, so RAEM/4 lifted the whole token and produced "RAEM4", which is not a prefix. Both already resolve correctly as plain calls (9A1AA -> 9A1, RAEM -> RA0), so the portable form now runs the base through that same rule and swaps the trailing numeral: 9A1AA/7 -> 9A7, RAEM/4 -> RA4. Letter-leading calls are unchanged. It also inherits simple()'s conservatism, which the old path bypassed: W1/7 and FN42/7 are a bare prefix and a grid, not callsigns, and now stay null instead of inventing W7 and FN7. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Optio Agent <optio-agent@noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…719) QSL_MANUAL is not a field in the ADIF spec, so strict importers (LoTW's validator, Club Log, other loggers) can reject or silently drop it. PR #701 fixed this on Android by moving the flag to APP_FT8AF_QSL_MANUAL — the spec's APP_<PROGRAMID>_<FIELD> escape hatch — but the desktop and iOS ports were missed and still emit the bare name. Neither port needs the APP_ field, because on both the tag is a hardcoded "N" carrying no information: desktop's QSL_RCVD tracks r.confirmed, but QSL_MANUAL was always N, and iOS's QsoRecord has no confirmation state at all. Nothing reads it back either — desktop has no ADIF import path, and the iOS parser ignores QSL flags. Android's importer keys on the field being present, so an absent field and an explicit N are the same import. Dropping it is therefore lossless. Covers both desktop emitters: the file export and adif_record(), which is what goes out over the WSJT-X "Logged ADIF" UDP message to JTAlert/N1MM — the one most likely to meet a strict parser. Fixes #697 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#720) * Self-syncing clock: auto-trim the clock offset from decode DT medians Opt-in Time Sync setting that makes the band itself the time source: each slot's per-decode DTs (fast pass only, own-TX echoes excluded) feed a pure ClockSelfSync estimator - median with MAD outlier rejection, >=4 surviving samples, 0.30 s deadband (the UI's "good clock" threshold), and two consecutive same-sign slots required before acting. Corrections apply a 0.5 proportional gain (damps the measure->correct->measure loop; no hard step cap, per the GpsClockUpdater step-limiter post-mortem) and fan out through the same three-way path as the manual control (UtcTimer.delay, GeneralVariables.manualTimeCorrectionMs, timeCorrectionMs config row), so RX windows, TX key-up, and persistence all follow. Stands down (and clears its confirmation streak) while GPS clock discipline owns the clock; the settings row is disabled then too, matching the manual-correction lock-out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Harden ClockSelfSync slot handling against concurrent decode threads Copilot review on #720 flagged two real ordering hazards: beginSlot dedup'd only on equality (a slow slot's delivery arriving after its successor's would be treated as new), and the beginSlot + onSlotDecodes pair was called as two separate synchronized sections, letting adjacent- slot threads interleave between dedup and streak update. beginSlot now rejects any utc <= the last processed slot, and a new atomic onSlot(utc, dt, delay) does dedup + decision under one lock; MainViewModel uses it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ommands (#721) * Voice assistant v1: spoken event announcements + push-to-talk commands Opt-in hands-free layer for mobile/POTA passenger operation and accessibility. Announcements (TTS, per-event toggles) ride the existing DxAlertNotifier decode/QSO hooks: station calling you (with SNR), QSO logged, new-DXCC CQ, new-prefix CQ. Callsigns are spelled letter-by-letter so engines don't read them as words. Two hard audio-safety rules from the TX pipeline docs are enforced: - TTS never plays while the rig is keyed (it would be mixed into the TX audio and transmitted): the announcer refuses to start an utterance during TX, and a mutableIsTransmitting observer hard-stops in-flight speech at key-up. Suppressed announcements don't burn their dedup key. - The push-to-talk mic button is disabled whenever FT8 RX holds an Android audio-capture session (phone mic, or Android-routed USB input) - SpeechRecognizer would fight our capture. Direct-libusb USB and LAN audio leave it available. Commands are a small offline keyword grammar (answer / call CQ / stop / skip / log it) mapped onto the same entry points the UI buttons use (callStation, userResetToCQ + setActivated, forceLogAndMoveOn), with a pure newest-caller selector for "answer" and a spoken echo of each action. New Voice Assistant settings category (5 toggles, config-table persistence with hydration arms); <queries> entries for RecognitionService and TTS_SERVICE. 59 new unit tests, all pure JVM. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Make the voice-command mic button react to its settings toggle live Device smoke test caught the button only appearing/disappearing after an app restart: the Composable read GeneralVariables.voiceCommandsEnabled as a plain static, which nothing invalidates. Add the house-pattern LiveData mirror (mutableVoiceCommandsEnabled) updated by the settings toggle and config hydration, and observe it from VoiceCommandButton. Verified on hardware: toggling now shows/hides the button immediately both ways. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address Copilot review on the voice assistant (PR #721) - Suppress the recognizer-error toast for ERROR_CLIENT: it's what cancel() (a deliberate second tap) emits, so toasting it made a user-initiated cancel look like a failure. Mapping extracted to the pure voiceErrorToastRes() and covered by tests. - VoiceAnnouncementDecisions.norm() now uppercases with Locale.ROOT so dedup keys are stable regardless of device locale (Turkish dotted-I regression test added). - Mic-gate strings no longer claim only the "phone mic" is the blocker - the gate also covers Android-routed USB input, and the wording now says so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…onflict
rig.rs's load_hamlib() does a bare dlopen("libhamlib.so.4") -- resolved
against whatever the dynamic linker finds first. On this machine that's the
distro-packaged libhamlib.so.4 (Hamlib 4.5.4), which has no QMX support at
all (confirmed: 0/283 rigs listed included QMX, and rig_init failed for
model 2057). A separately-built Hamlib 4.7.1 already exists at
/usr/local/lib/libhamlib.so.4 with QMX support (RIG_MODEL_QRPLABS_QMX
present) -- same soname as the system package, so whichever the linker
resolves first wins for any process that doesn't override the search path.
Not fixed by upgrading the system package: other software on this machine
(CQRLOG) depends on the distro Hamlib specifically. desktop/linux/run-ft8af.sh
instead sets LD_LIBRARY_PATH just for FT8AF's own process, so it
preferentially finds the newer build without touching anything system-wide.
Falls back to the system Hamlib (previous behavior) if no /usr/local build
exists.
Verified end-to-end through the wrapper: rig list jumps from 283 to 321
entries (QRPLabs QMX now included), and the app made a real, live CAT
connection to a physical QMX (kenwood_transaction traffic, frequency set,
54ms round-trip) -- confirmed via screenshot showing "rig: qmx" instead of
"no rig".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e widget background select and input elements set background: var(--panel) (dark), but WebKitGTK's native rendering doesn't fully respect that for <select> -- confirmed live, the rig/band dropdown and text fields render with a light background regardless, so the existing color: var(--text) (light, #d7e0e8 -- meant for dark panels) made every text box's contents nearly unreadable. Verified live via the new runtime styles.css: edited the on-disk file, relaunched with no rebuild, confirmed all Settings-tab inputs (Callsign, Grid, Display name, Radio) and both dropdowns (rig/band picker, Backend, Input/Output device) now render crisp black text. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same root cause as the earlier unreadable-text fix (light --text color on a light native widget background), but a separate CSS property: caret-color was never set, so the blinking text cursor was effectively invisible. This explains a real, confusing user-facing symptom: with no visible caret, retyping a value without first clearing the field silently inserts new characters at the actual (but invisible) cursor position instead of replacing anything -- confirmed live, a garbled "AI5IIAI5IIAI555" ended up saved after a few real attempts to enter a callsign, matching exactly what duplicate-insertion-without-clearing produces. Root-caused by resetting the field to empty and typing a single character with the caret-color fix applied: cursor now renders as a clear, visible black line, confirmed both in a screenshot and by the user directly on the live display. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This reverts commit d42c4a8.
…ht native widget background" This reverts commit fb86646.
… field The earlier global select/input color override broke real keyboard input entirely (reverted in fb86646/28463d2 -- see project memory: project_ft8af_webkitgtk_input_bug.md). This is narrower and confirmed different: targets only the one Display name <input> via its placeholder text as a CSS attribute selector (no JSX change, no rebuild needed), not the shared select/input rule that also matches every native <select> dropdown. Verified live, twice, with a real keyboard: text renders white and readable, and typing into the field still works normally -- unlike every variant of the global rule change, which broke input every time it was tried. Callsign/Grid and the <select> dropdowns are untouched by this commit and still use native-default text color. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-ups from the /code-review pass on PR #796. Transmit - playTuneTone honoured neither the TX channel selection nor the sink: on a splitter cable with TX=Left the over drove one rig and Tune keyed both. The AudioTrack layout decision and its frame arithmetic move into TxChannelLayout (buffer budget, samples-vs-frames drain accounting, the 8-frame pad, the stereo interleave) and both playViaAudioTrack and playTuneTone resolve through it. The stereo scratch buffer is allocated once per playback instead of per 50 ms chunk. - Default sink now always gets the mono open. We cannot see what Android routes Default to, and a one-sided stereo open landing on a mono route (mono UAC HAL, BT SCO rig link) is downmixed to half drive with nothing in the UI. The settings row greys out on Default and says to pick a device by name; a named device with an unreported count still honours the choice. - UsbAudioDevice.writeAudio's interleave is extracted to interleavePcm16 and tested at the byte level. Mono detection - The TX selector could never grey out for a mono USB-direct card: setActiveOutputDevice() has no callers (playViaUsbAudio opens and closes per over), so the query always read null. UsbAudioDeviceInfo now carries channel counts judged from the endpoint descriptors at enumeration, and the settings query matches the persisted VID:PID against that (the RX side still prefers the open device, which has negotiated a real rate). - The RX row greys out on network rigs (Icom WLAN, Flex, X6100, tr-uSDX over CAT), whose audio bypasses MicRecorder; the selector could only ever reopen an unused AudioRecord there. AudioChannelRowGate holds the gate decisions for both rows so each greyed state carries its own note. Full duplex - Ft8Message.isOwnEcho, set by OwnTxEchoFilter at the one place that decides "this is us", replaces per-consumer callsign compares: the map skips echoes (no marker for ourselves at our own grid), the decode chips never offer them and the "show only" settings never hide them (they appear under All, in decode order, and nowhere else), the SWL QSO scan strips them by list contents rather than by the live toggle (echoes outlive the toggle in ft8Messages), and tap-to-call recognises a compound call echoing as its bare base call the way the filter does. - Echoes get country/continent resolved like every other row, through a path that skips Needed-DX alerts so our own callsign can never raise one. - The echo-only branch shares finishDisplayPass with the main path. RX reopen - The channel change no longer reopens the capture on a composition-scoped coroutine (a back press in the same gesture as the tap dropped the reopen and left the stored setting and the open capture disagreeing). MainViewModel.onRxAudioChannelChanged reopens only when the open configuration actually changes (Mix <-> L/R on AudioRecord, any change on USB-direct; Left <-> Right on AudioRecord is folded live), debounced 400 ms on a daemon thread that outlives the screen. Tests: 3718 green, 48 new — TxChannelLayoutTest, UsbAudioTxInterleaveTest, MicRecorderChannelReopenTest, AudioChannelRowGateTest, OwnEchoMarkerTest, OwnRowGuardTest, plus cases in DecodeFilterTest, FullDuplexMonitorTest and OwnTxEchoFilterTest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UAZBEHb5BNchdjHBM8DGmF
Extends the single-field-scoping approach validated in 9680d65 (Display name input) to every select that was unreadable against its native light background: Band, Rig control (Backend/Radio/Connection/Serial port/Baud), Audio (Input/Output device), and Developer waterfall FFT (Window function/FFT size/Averaging). Each gets its own id and a CSS rule targeting just that id -- the shared select/input rule stays untouched, since overriding color there is what broke real keyboard input earlier (see project_ft8af_webkitgtk_input_bug.md). Verified live with a real keyboard across two rebuild/retest cycles: closed-select display text and open dropdown-list text both render black and readable, Callsign/Grid typing still works normally, and none of the scoped selects show any of the input-blocking behavior the global rule change caused. Also commits main.rs's `--list-audio` debug helper (used earlier tonight to diagnose the audio device list, analogous to the existing --list-rigs) -- prints cpal's enumerated input/output devices and exits. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add RX/TX audio channel select, mono detection, and full-duplex monitoring
Promote dev → staging (RX/TX audio channel select, full duplex monitoring)
New RX gain slider (0-100%, default 100%/unity) mirrors the existing TX gain pattern end to end: a lock-free atomic in the audio capture callback (AudioInput::set_gain(), read fresh per sample on the realtime thread, no locking), an EngineCommand::SetRxGain handler that persists to config and applies live without restarting capture, a set_rx_gain Tauri command, and an ipc.ts wrapper. Placement: next to the All/CQ/To me filter chips on the Decode screen, not buried in Settings -- real feedback was that this needs adjusting per-band (noise floor varies a lot band to band) while actively watching decodes, the same reasoning that will apply to relocating TX gain next to a future Tune button. Range settled at 0-100% (matching TX gain) after live testing: temporarily widened to 0-800% to confirm the control had real effect (confirmed: mute at 0%, a genuine clip warning near 800%, proportional movement of the raw dBFS meter in between), but adjustment past 100% wasn't practically useful -- the QMX's USB audio codec has no analog preamp stage (confirmed via amixer: only a capture on/off switch, no volume control), so there's no real gain headroom on the input side to exploit past unity. Settled on 0-100% for finer control resolution across the range that matters. Both TX and RX gain sliders now debounce their backend IPC call (~120ms after the last onChange) while keeping the displayed value instant -- dragging fires far more onChange events than needed, and that load stacking on the waterfall's own frequent canvas redraws correlates with a real WebKitGTK renderer crash observed live (the WebKitWebProcess child disappeared entirely, leaving a blank window, while the Rust backend kept running) -- not proven as the sole cause, but a safe mitigation regardless. Also clarified: the waterfall's lack of visible change across the gain range is by design (its own noise-floor-relative auto-leveling, stated in its own code comment), and the raw "audio dB" meter reading "silent" at low gain while decodes keep working is expected too -- it measures broadband RMS while the decoder/waterfall work in narrow FFT bins, and FT8 is designed to decode at very low SNR. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add branded POTA activation sharing on Android
Dev -> Staging
# Conflicts: # desktop/src-tauri/src/engine.rs # desktop/src-tauri/src/main.rs # desktop/src/App.tsx
Select readability (styles.css): the per-id `color: #000` fixed the Linux
symptom but broke the other two platforms. WebKitGTK draws <select> as a
native GTK widget and ignores the author background, so --text sat on a
light control; WebView2 and WKWebView *do* honor the dark --panel
background, so black text there was black-on-#1a2129 -- and CI ships all
three. Opt selects out of native rendering with `appearance: none` so the
theme actually paints everywhere, redraw the arrow as a background image,
and pin both colors on the option list, which the platform draws outside
the page. Drops the dead #rig-radio-select selector along the way.
Stale on-disk stylesheet (main.rs): the seeded copy was frozen after the
first launch, so a later release that ships new markup would render it
unstyled with no in-app reset, and an interrupted first write left a
0-byte file that read back Ok("") and injected an empty <style> forever.
Stamp the seed with an FNV-1a hash of its own body: a file whose stamp
still matches is an untouched seed and is taken back when the compiled
default changes; an edited file, an unstamped one, or one we never wrote
is left alone. Empty or whitespace-only now re-seeds.
Unstyled fallback (main.tsx): with the bundled CSS import removed and
index.html linking nothing, the IPC read was the only source of styling
and the catch rendered anyway -- `npm run dev` in a plain browser, where
invoke rejects, came up as bare HTML. Fall back to the compiled-in copy.
RX gain: docs on SetRxGain, the engine field and set_gain still claimed
0.0-2.0 after the range was finalized at 0.0-1.0. Non-finite input now
falls back to the default instead of being clamped -- f32::clamp
propagates NaN, and the result is persisted where "NaN" parses back
cleanly, so one NaN would kill RX across restarts. The new full-scale
clamp in push_mono applies only above unity: at or below it, it could
only ever clip samples the device already delivered out of range, which
hard-clips the slightly-over-scale samples F32 backends (CoreAudio,
JACK/PipeWire) hand out on an otherwise-default install.
Linux launcher: LD_PRELOAD the one Hamlib rather than prepending
/usr/local/lib to LD_LIBRARY_PATH, which redirected every library FT8AF
resolves; and fall back to an installed ft8af on PATH so the script works
outside a source checkout, which is where its own docs send it.
Debounce: extracted to src/debounce.ts so it is testable, and cancelled
on unmount so a drag in flight cannot fire IPC after teardown.
Tests: resolve_styles/stamp round-trip and recovery cases, clamp_rx_gain
and the non-finite fallback for both gains, push_mono gain/clip/downmix
behavior, and the debounce module.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D7Z6xhcHB3XspStFrQ5puU
Runtime-loaded styles.css; Linux Hamlib/QMX launcher wrapper; readable form-field text
Promote dev → staging (desktop runtime CSS, RX gain control, Linux launcher)
A merge to `main` no longer touches Google Play in any way. `android.yml` already uploaded no AAB on a main push (PR #794); `play-listings.yml` was the last thing that did — a main push touching `fastlane/metadata/android/**` published the changed locales to the store as a side effect of the promotion. Drop that push trigger. Listing text still lands in the repo on the normal feature -> dev -> staging -> main flow and is still validated on the way in (the PR gate runs the completeness/character-limit tests unchanged), but it reaches the store only when someone runs the publish on purpose: the manual workflow_dispatch (dry-run / check-permissions / publish) or `.github/scripts/publish_listings.py` locally. Also make MODE fall back to the read-only `dry-run` instead of `publish`, so a mode that somehow arrives empty sends nothing to Play. No new code path to test: the change is workflow triggers plus docs, and the 135 publish_listings unit tests pass unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNDpfHeom9W1c7spiGxP6H
Make the Play listing publish manual-only
Promote dev → staging (Play listing publish is manual-only)
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #808 +/- ##
============================================
+ Coverage 36.62% 42.41% +5.78%
- Complexity 197 228 +31
============================================
Files 216 270 +54
Lines 26885 32450 +5565
Branches 3294 3735 +441
============================================
+ Hits 9847 13764 +3917
- Misses 16811 18410 +1599
- Partials 227 276 +49
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Play rejected versionCode 2100 under "Auto App Quality Guidelines: Visual
info on phone — your app does not disable features requiring phone
interaction while in driving mode". The review evidence is the car-screen
idle state, which read "Open FT8AF on your phone to start the FT8 engine":
the car both instructed phone interaction and left it available while
driving.
The idle template is now status only ("FT8AF is not on the air yet. QSO
status appears here once it is running."), and the one action that does
reach the phone — starting the app so it can create the engine — is
wrapped in ParkedOnlyOnClickListener, so the Auto host runs it only when
the car is parked and otherwise shows its own "not available while
driving" notice.
Template content is split out from resource lookup so the guarantee is
unit-testable: CarIdleTemplateTest pins the action as parked-only, and
checks the shipped string itself for phone-interaction wording so a later
copy edit can't quietly reintroduce the rejection.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B4bi57TiMribneqcSBddRB
Second Auto rejection in two months, so take Play's other path and stop
shipping as an Auto app rather than keep answering review findings.
versionCode 2100 was rejected under "Visual info on phone — your app does
not disable features requiring phone interaction while in driving mode";
versionCode 1327 was rejected in July under the NAVIGATION category ("does
not load map and user location"). The IOT/templates shape restored after
that first removal is what drew this one. No approved Auto category fits a
ham-radio QSO monitor well enough to be worth the review cycle right now.
- AndroidManifest.xml: drop the com.google.android.gms.car.application
descriptor, the androidx.car.app.minCarApiLevel meta-data and the
FT8AFCarAppService service. Nothing marks the app as Auto-enabled, so
Play no longer routes it through Auto app-quality review.
- Delete res/xml/automotive_app_desc.xml (dead once the descriptor is gone).
- CarAppManifestWiringTest: flip back from "AA is wired in the approved IOT
shape" to a guard asserting AA is unwired, with both rejections recorded
so a third revival is a deliberate act.
The car/ Kotlin package, its tests and the androidx.car.app:app dependency
stay in-tree (dead but compiling), as they did after the July removal, so
the feature can be revived. It carries the previous commit's driving-mode
fix, so a revival starts compliant with the finding that triggered this.
The debug-only AAOS scaffolding (src/debug CarAppActivity +
DebugInjectReceiver) is untouched; it never merges into release.
Verified: full testDebugUnitTest suite (3724 tests, 0 failures) plus
processReleaseMainManifest — the merged release manifest keeps only the
car-app library's own entries (connection provider, permission activity,
notification receiver), which carry no Auto descriptor or CarAppService.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B4bi57TiMribneqcSBddRB
…ompt Remove Android Auto from the shipping app (clears the driving-mode rejection)
Promote dev → staging (remove Android Auto from the shipping app)
The staging build android-dev.1231 uploaded its AAB but could not commit the Play edit: Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true. so no internal release appeared. Google's edit commit demands one specific value for changesNotSentForReview and rejects the other, and which one it wants depends on Play Console review state that changes outside CI — a normal reviewed app rejects the flag, an app with review-gated changes pending requires it. Both directions have broken a staging upload now (false today, true back at android-dev.1026), so stop hand-flipping the literal: keep the normal-path value as the first attempt and retry once with the opposite value when the commit is refused. The failed edit is never committed, so its versionCode stays unused and the retry can re-upload the same AAB. A successful retry means the release is on the track but was NOT sent for review, so it emits a warning saying a human has to submit the pending changes in the Play Console. The failure warning and the run summary's Play outcome now both account for the retry. No unit test accompanies this: the change is entirely GitHub Actions workflow wiring, which the repo's JVM test suite cannot reach. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ht2BB4TWtkmC5EW5GsPXEM
Retry the Play publish with the opposite review flag
Promote dev → staging (retry the Play publish with the opposite review flag)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes the validated staging build to production.
Candidate being promoted
android-dev.1225— built from run 34255548471 on the#807merge. GitHub prerelease ✅, AAB to the Play internal track ✅.Its body carries
<!-- ft8af-version: 0.150.0 -->, so this merge ships 0.150.0 (up fromandroid-v0.149) and reuses these notes rather than re-rolling them:What merging this does
android-v0.150.0tag + full GitHub Release (signed APK attached, R8 mapping as an artifact).desktop-v*release with the Windows/macOS/Linux bundles.push: branches: [main]trigger is gone fromplay-listings.yml. A merge tomainnow reaches Play in no way at all.Shipping it to users
Deliberate and separate, in the Play Console: promote the
android-dev.1225build already on the internal track to production. (Theandroid-v*tag lane inandroid.ymlcan still upload an AAB via a manualworkflow_dispatchrun on the tag, but that is not needed if you promote in Console.)Notable in this promotion
Beyond the release notes above: #806 made the Play listing publish manual-only, and #802 added the POTA activation image sharing. Note the sharing feature's
POST https://ft8af.app/api/activationsbackend still answers{"error":"Activation sharing is not available yet."}, so that feature is inert until the server side ships — shipping 0.150.0 does not turn it on.🤖 Generated with Claude Code
https://claude.ai/code/session_01XNDpfHeom9W1c7spiGxP6H