Skip to content

Promote staging → main (production release 0.150.0) - #808

Open
patrickrb wants to merge 641 commits into
mainfrom
staging
Open

Promote staging → main (production release 0.150.0)#808
patrickrb wants to merge 641 commits into
mainfrom
staging

Conversation

@patrickrb

Copy link
Copy Markdown
Owner

Promotes the validated staging build to production.

Candidate being promoted

android-dev.1225 — built from run 34255548471 on the #807 merge. GitHub prerelease ✅, AAB to the Play internal track ✅.

Its body carries <!-- ft8af-version: 0.150.0 -->, so this merge ships 0.150.0 (up from android-v0.149) and reuses these notes rather than re-rolling them:

New: choose which audio channel feeds RX and TX for stereo interfaces, a live RX gain control, full-duplex monitoring, and better Bluetooth headset and A2DP audio routing. Share a branded POTA activation image, and see mode and time-ago in the POTA contact list. Plus IC-705 CAT fixes and many crash and rig-control fixes across Icom, Yaesu, Kenwood, Flex and Xiegu.

What merging this does

Shipping it to users

Deliberate and separate, in the Play Console: promote the android-dev.1225 build already on the internal track to production. (The android-v* tag lane in android.yml can still upload an AAB via a manual workflow_dispatch run 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/activations backend 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

patrickrb and others added 30 commits July 24, 2026 10:25
…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>
laniol8926 and others added 22 commits September 4, 2026 17:08
…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>
…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
# 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

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.27795% with 105 lines in your changes missing coverage. Please review.
✅ Project coverage is 42.41%. Comparing base (9491c32) to head (2b951dd).

Files with missing lines Patch % Lines
...debug/kotlin/radio/ks3ckc/ft8af/car/DebugInject.kt 61.70% 35 Missing and 1 partial ⚠️
desktop/src-tauri/src/main.rs 69.09% 34 Missing ⚠️
desktop/src-tauri/src/audio/input.rs 57.14% 27 Missing ⚠️
desktop/src-tauri/src/engine.rs 83.67% 8 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             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     
Flag Coverage Δ
android 17.21% <61.70%> (+2.17%) ⬆️
desktop 63.95% <70.88%> (+1.49%) ⬆️
ios 97.71% <ø> (+1.22%) ⬆️
native 9.93% <ø> (ø)

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

Files with missing lines Coverage Δ
desktop/src-tauri/src/db.rs 93.69% <100.00%> (+4.08%) ⬆️
...pp/src/main/kotlin/radio/ks3ckc/ft8af/AppInsets.kt 66.66% <ø> (ø)
...n/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt 2.27% <ø> (-0.05%) ⬇️
...app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt 0.42% <ø> (-0.08%) ⬇️
...p/src/main/kotlin/radio/ks3ckc/ft8af/ScreenWake.kt 100.00% <ø> (ø)
...main/kotlin/radio/ks3ckc/ft8af/car/CarQsoStatus.kt 94.44% <ø> (+0.24%) ⬆️
...n/kotlin/radio/ks3ckc/ft8af/car/QsoStatusScreen.kt 5.92% <ø> (+5.92%) ⬆️
...tlin/radio/ks3ckc/ft8af/car/RecentDecodesScreen.kt 0.00% <ø> (ø)
...tlin/radio/ks3ckc/ft8af/hunt/HuntTargetSelector.kt 56.57% <ø> (ø)
...tlin/radio/ks3ckc/ft8af/pota/PotaSessionManager.kt 13.63% <ø> (+0.73%) ⬆️
... and 56 more

... and 58 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

patrickrb and others added 7 commits September 9, 2026 13:03
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants