Skip to content

Release: dev → main (PRs #151–#172)#165

Open
patrickrb wants to merge 66 commits into
mainfrom
dev
Open

Release: dev → main (PRs #151–#172)#165
patrickrb wants to merge 66 commits into
mainfrom
dev

Conversation

@patrickrb

@patrickrb patrickrb commented Jun 8, 2026

Copy link
Copy Markdown
Owner

Release: devmain

Rolls up 20 merged PRs (#151#172) since the last release (#150). Highlights: in-app POTA upload with federated sign-in and an activation map, FT4 and FT2 modes, FT8 DXpedition Hound mode, a QSO distance/path map, manual time correction for offline operating, and several TX-audio, decode-list, and layout fixes. +11,838 / −542 across 113 files, with extensive new unit coverage.

✨ New features

🎛️ UX & decode improvements

🐛 Fixes

🧰 Docs & tooling

✅ Testing

New unit coverage added across the release: FT4/FT2 ModeProfile, own-TX echo filter, QSO path projection / distance helpers, QSO panel + sheet log builders, POTA activation ADIF builder and activation-map history, manual time correction, WebView token stripping, geo-outline parsing, FT8TransmitSignal, and UtcTimer.

🤖 Generated with Claude Code

patrickrb and others added 30 commits June 7, 2026 12:49
The volume slider had no effect when TX audio was routed to the rig's
USB sound card via AudioTrack: the rig overdrove at any slider position,
including 0%. The AudioTrack path applied volume only via
AudioTrack.setVolume(), but when a track is routed to a USB Audio Class
device, Android frequently delegates level control to the device's
hardware volume and the per-track setVolume() is a no-op, so the samples
left at full scale regardless of the slider.

Bake the gain into the float samples before write() (mirroring the
USB-direct playViaUsbAudio path) and keep the track at unity to avoid
double-attenuation on routes where setVolume() does work. Remove the
now-redundant mid-cycle setVolume() observer (volume is fixed per cycle,
matching the ALC auto-volume model). Also log the applied volume and
autoVolume state on the TX-path branch line so this is diagnosable from
debug.log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the inline volume-scaling loop (used by both the AudioTrack and
USB-direct paths) into a pure static FT8TransmitSignal.applyVolume(), so
the level logic behind the overdrive-at-0% fix is unit-testable, and make
float2Short package-private static. Wrap the static System.loadLibrary in
try/catch (mirroring GenerateFT8) so the class loads on the bare JVM and
JaCoCo can instrument it — previously the class registered no coverage.

New FT8TransmitSignalTest (plain JUnit + Truth) covers both helpers,
including the key case that 0% volume yields digital silence. applyVolume
and float2Short are now at 100% line/instruction coverage.

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

Fix TX volume slider having no effect on USB-routed audio (overdrive at 0%)
When the rig monitors TX audio back to line-in, the decoder hears and
decodes our own transmission. That loopback echo was leaking into the
message list and the QSO conversation panel, producing a duplicate of
every TX: the panel's synthesized key-up entry (text " (NNNNHz) MSG",
wall-clock timestamp) plus a decoded-loopback TX row (plain
getMessageText, cycle-boundary timestamp). The two never deduped because
their strings differ by the frequency prefix, so both rendered ~1s apart
and the stray loopback row also made the exchange look out of order.

Fix: drop decodes whose sender is our own callsign in
MainViewModel.afterDecode (own callsign in the "from" field can only be
loopback), before they reach the message list, QSO panel, or SWL
database. Remove the now-dead loopback TX branch in ActiveQsoPanel so TX
rows come solely from the synthesized key-up entry. PSKReporter and the
auto-sequence already ignored own-callsign messages, so behavior there is
unchanged.

Also add a per-cycle DECODE diagnostic (kept/ownEcho/replyToMe/slot) to
help confirm the separate, unverified "missing other station responses"
report from a pulled debug.log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the two new codepaths from the loopback-echo fix into pure,
testable units and cover them:

- OwnTxEchoFilter (new): the decode-cycle filter that drops own-callsign
  loopback echoes, pulled out of MainViewModel.afterDecode. Covers
  dropping/counting echoes, the replyToMe diagnostic flag, empty input,
  unset callsign (drops nothing), compound-callsign base-call matching,
  and input immutability. 9 tests.
- buildQsoLog (extracted in ActiveQsoPanel): the QSO conversation panel's
  RX/BUSY/TX classification and time ordering. Covers RX/BUSY/ignore,
  that own loopback never becomes a TX row, synth entries as the sole TX
  source, ascending utc ordering, and case-insensitive target match.
  8 tests.

No behavior change — afterDecode now delegates to OwnTxEchoFilter.filter
and the composable delegates to buildQsoLog. All 17 tests green via
testDebugUnitTest (Robolectric + Truth, matching the existing suite).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address the Codecov patch-coverage gaps by moving the remaining logic into
the pure helpers and covering every branch:

- OwnTxEchoFilter: add decodeLogLine(slot), moving the DECODE diagnostic
  String.format out of the (untestable) MainViewModel.afterDecode body so
  the format is covered and afterDecode shrinks to a single fileLog glue
  line. Now 100% line + branch (2 new tests).
- buildQsoLog: drop the dead `?:` fallback on getMessageText() (the Java
  method never returns null, so the fallback was an uncoverable partial),
  collapse the when into an early-return + RX/BUSY ternary, and cover the
  null-message-list, null-callsign, null-recipient, and
  checkIsMyCallsign-recipient branches. Now 100% line + branch (5 new
  tests).

The residual uncovered lines are in MainViewModel.afterDecode and the
ActiveQsoPanel @composable body, which cannot run in JVM unit tests:
constructing MainViewModel chains into FT8SignalListener's
`System.loadLibrary("ft8cn")`. All decision logic now lives in the two
covered helpers; what remains there is LiveData / native-bound glue.

23 tests total, all green via testDebugUnitTest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hide own-TX loopback echoes from QSO panel and decode log
POTA's activation-log upload is gated by AWS Cognito (SRP) then a plain
multipart POST to api.pota.app/adif. The pool/client IDs are public (they
ship in pota.app's JS bundle and the open-source pota-adif-upload crate), so
the app can log in with the user's pota.app account and upload directly
instead of bouncing through the website.

The pota module already produced correct per-park ADIF and hit the public
spot endpoints; the only missing piece was the Cognito login.

- PotaAuth: USER_SRP_AUTH login via aws-android-sdk-cognitoidentityprovider,
  refresh token persisted in a private SharedPreferences, fresh ID tokens
  minted via REFRESH_TOKEN_AUTH (raw JSON POST, no SDK).
- PotaClient.uploadAdif()/getJobs(): multipart POST /adif with the raw ID
  token in Authorization (no Bearer prefix), plus job-status read.
- PotaAdifExporter.buildActivationAdif(): extracted so the share-sheet and
  upload paths emit identical bytes.
- PotaScreen History tab: primary "Upload to POTA" button + sign-in dialog;
  first upload prompts login, then it is silent. Share-ADIF / Open-pota.app
  fallbacks are unchanged.

Spike scope: refresh token is stored in plaintext (matches the existing QRZ
password storage) -- EncryptedSharedPreferences is a planned follow-up. Rides
POTA's undocumented API, so the manual fallbacks stay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Volume was baked into the per-cycle audio buffer, so a slider change only
applied on the next over — there was no way to pull drive down during a
transmission, a hardware-damage risk users reported. Move volume from a
once-per-cycle bake to a live, per-chunk read on every TX path that streams
to a radio, so dragging the slider down ramps the on-air level within tens
of milliseconds.

- USB-direct (libusb): add a g_outputVolume atomic + nativeSetTxVolume JNI in
  usb_audio_capture.cpp; scale each int16 sample as it is copied into the iso
  transfer buffers (prime loop + onOutputComplete). Java now passes full-scale
  PCM. Latency ~= buffered-ahead window (~32ms).
- AudioTrack (Android sink): MODE_STATIC one-shot write -> MODE_STREAM with a
  small (~200ms) buffer, written in ~50ms chunks each scaled by the current
  volumePercent. Worker thread owns stop()/release(); STOP flips a cancel flag
  and pause()+flush()es for instant silence, which also unblocks a worker stuck
  in a blocking write (no release race / deadlock).
- USB-direct UsbRequest fallback: scale each chunk live as it is sent.
- truSDX CAT: apply volume around the 8-bit midpoint (128) per 256-byte send
  chunk, re-applying the 0x3B->0x3A escape after scaling.
- Wire every volumePercent change (slider, hardware buttons, ALC auto-volume)
  to the native loop via one observeForever in ComposeMainActivity.

ICOM/XieGu UDP paths already scaled live per packet and are unchanged.

Tests: add coverage for floatToInt16NoPad (no per-chunk pad) and the chunked
per-offset slicing (a mid-stream volume change attenuates only later chunks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Workflow note directing every separate line of work to its own git
worktree instead of branch-switching the primary checkout, which collides
with in-flight builds and adb installs. Includes the fresh-worktree gotchas:
the untracked cpp/ native sources must be copied over or the NDK build fails,
and build/install still goes through the Windows gradlew.bat wrapper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
State explicitly that no work — including one-line docs/config changes —
lands via direct commit to dev or main, and that all PRs target dev.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document git worktree workflow in CLAUDE.md
The QSO bottom sheet now surfaces how far away the station you're
calling is and where it is in the world:

- New Distance stat card sits between Signal and Azimuth, formatted in
  the operator's preferred unit (mi/km) via MaidenheadGrid.
- A compact equirectangular path map auto-zooms to frame the operator's
  grid and the remote grid, draws Natural Earth land outlines (reusing
  WorldOutlines), and connects the two stations with a dashed line.
  Longitudes are normalized across the antemeridian so trans-Pacific
  paths take the short way and wrapped continents still render.
- The map card footer shows the DX country/state line (reusing
  formatLocationLine over message.fromWhere + UsStateLookup).

The map and its trailing spacer render only when both grids are known,
so nothing shifts when a decoded message lacks a grid.

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

Extract the path-map framing math out of the QsoSheet DrawScope code into a
pure, no-Android `QsoPathProjection` class so it can be unit-tested without a
Canvas. `drawQsoPath` now constructs it instead of inlining the antemeridian
normalization, bbox centering, span clamping, and uniform-scale math.

Tests:
- QsoPathProjectionTest (plain JUnit): longitude normalization across the
  date line, midpoint-centers-the-canvas, north-is-up, minimum-span clamp,
  and that both endpoints stay on-canvas for a trans-Pacific path.
- QsoSheetLogicTest (Robolectric): computeDistanceText placeholder/format
  branches and gridToLatLon null/parse/decode branches. The two helpers were
  made `internal` for test visibility.

Also add a Testing section to CLAUDE.md: every new code path requires a new
test, with the extract-logic-from-Composable pattern and how to run the suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add res/raw/us_states.json (a ~90 KB GeoJSON FeatureCollection of the 50
states + DC) and a UsStateOutlines loader alongside WorldOutlines. The
shared FeatureCollection parsing is factored out into parseGeoJsonRings so
both land and state datasets use one code path.

drawQsoPath now takes a stateRings list and strokes the state boundaries
(no fill, fainter/thinner than the coastline) over the land fill, using the
same projection and -360/0/+360 lon-offset repetition as the land so the
Aleutians don't drop out. State lines only matter when a US endpoint is in
frame; off-frame they clip away.

Test: GeoOutlinesParseTest covers parseGeoJsonRings — Polygon, MultiPolygon,
hole-skipping, non-polygon-skipping, and the empty case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The decode page filter (All / CQ Calls / CQ POTA / New DXCC / Needed /
For Me) was stored in a local rememberSaveable, so it reset to "All"
every time the user navigated to another tab and back. The tab switcher
in FT8USApp swaps screens with a `when` block, which disposes and
recreates DecodeScreen, discarding composable-local state.

Hoist the selection into MainViewModel.decodeFilter (MutableLiveData),
mirroring how the QSO bottom-sheet state already survives navigation.
DecodeScreen now observes it and writes via postValue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A new button in the decode top bar (next to Compact and Clear) toggles
"clear every cycle" mode. When on, the decode list is wiped at the start
of each cycle so it only ever shows the current slot; when off, decodes
accumulate as before.

- GeneralVariables.clearDecodesEveryCycle: new persisted flag.
- DatabaseOpr: load the flag from config key "clearDecodesEveryCycle".
- MainViewModel: clear ft8Messages before adding a non-deep cycle's
  decodes when the flag is on (deep decodes augment the cleared cycle,
  so they don't re-wipe it).
- FT8USIcons.AutoClear: circular-arrow icon for the toggle, tinted
  Accent when on / TextMuted when off.
- DecodeScreen: the toggle button, persisting via writeConfig like the
  Compact (msgMode) button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HUNT (auto-answer CQ) and the CQ button were independent, so both could
be active at once. Disable each while the other is selected: the CQ
button greys out and stops responding while HUNT is on, and the HUNT
pill greys out while you're actively running CQ. Pressing STOP returns
to the neutral state where either mode can be chosen again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SRP email+password path can only authenticate accounts that have a
Cognito password. POTA users who sign in with Google / Facebook /
Login-with-Amazon are federated identities with no pool password, so SRP
fails for them with no in-app recourse.

Add the Cognito hosted-UI authorization-code + PKCE flow, which covers
every login method POTA offers (the managed login page presents email,
Google, Facebook and Amazon together). It produces an ordinary refresh
token, so the existing idToken()/refreshIdToken() + /adif upload path is
unchanged downstream.

POTA's Cognito app client registers exactly one redirect URI
(https://pota.app/) and rejects custom schemes / localhost, so Chrome
Custom Tabs can't intercept the code. A WebView we control can: it watches
navigation and lifts ?code= out the instant Cognito redirects, before
pota.app loads. The default WebView UA ("; wv") trips Google's
disallowed_useragent block, so the WebView uses a plain Chrome UA.

- PotaAuth: newPkce()/authorizeUrl()/exchangeCode() + token-endpoint POST
  and id_token email-claim parsing for display.
- PotaOAuthLogin.kt (new): full-screen WebView dialog driving the flow.
- PotaScreen: "Sign in with Google / Facebook / Amazon" button in the
  existing login dialog hands off to the WebView; success resumes the
  pending upload.

assembleDebug green. Live federated round-trip still needs on-device
verification with a real federated pota.app account.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
observeForever was registered in onCreate and never removed, so every
activity recreation (rotation, theme/locale change, process restart)
added another observer and fired a redundant native setTxVolume per
change. Switch to observe(this) so the observer is auto-removed on
destroy. TX runs with the activity foregrounded (STARTED), so
STARTED-only delivery loses no updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tap now writes through mainViewModel.decodeFilter rather than
directly into selectedFilter; reword the comment accordingly
(addresses Copilot review note on PR #158).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- computeDistanceText: go through gridToLatLng + getDistLatLngStr so two
  stations in the same grid square show "0 mi/km" instead of "--". Only
  unparseable grids now fall back to the placeholder (getDistStr collapsed
  the real-zero and unknown cases together). Adds a regression test.
- QSO path map: project the land + US-state ring sets into screen-space
  Paths once via Modifier.drawWithCache instead of rebuilding the whole
  geometry on every draw pass. The per-frame draw (recompose + sheet
  slide-in animation) now only issues drawPath calls, avoiding jank.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address Copilot review on PR #159 and a long-standing Clear-button lag.

Clear-every-cycle: move the per-cycle wipe from afterDecode() to
beforeListen() so it fires at the start of every slot, before decoding.
The old placement only cleared when a non-deep cycle reached the append
block, so a silent slot (zero decodes, or all decodes filtered as own-TX
echoes -- both return early from afterDecode) left the previous slot on
screen, contradicting the "only the current slot" intent.

Clear button: the decode screen observes mutableFt8MessageList with
structural equality, but every post handed back the same mutated
ft8Messages instance, so Compose saw no change and only refreshed when
the 1 Hz clock incidentally recomposed -- up to a second later. Tapping
Clear felt dead. Add publishFt8MessageList(), which posts a defensive
copy so Compose gets a structurally distinct value and the UI updates
immediately; route afterDecode, clear, and the QTH-enrichment runnable
through it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the Hound side of FT8 DXpedition (Fox/Hound) mode: call a
DXpedition "Fox" high in the 1000-4000 Hz band, auto-QSY down to where the
Fox answers, reply with the report, and log on RR73.

The Fox combo message ("CALL RR73; CALL2 <hash> rpt", i3=0/n3=1) already
decodes and surfaces to Java on this build (verified on-device against a
WSJT-X ft8code reference frame), so no native/decoder work is needed. The
Hound only ever transmits standard i3=1 messages (grid-call + R+rpt), which
already encode, so there are no DSP changes.

- Ft8Message: fix cosmetic double-sign in the i3=0/n3=1 combo formatter.
- GeneralVariables: houndMode + houndFoxCall flags.
- FT8TransmitSignal: startHound() + handleHoundCycle(), a dedicated Hound
  QSO handler gated behind houndMode (standard sequencer untouched). Locks
  TX to the odd slot, reuses getFunctionCommand orders 1 (grid) and 3
  (R+rpt), auto-QSYs to the Fox frequency on invite, logs on RR73.
- MainViewModel: startHoundMode()/stopHoundMode() (disables Hunt, which is
  mutually exclusive).
- TxStrip: new "DX" chip; FT8USApp: HoundSetupSheet (Fox call + call freq).

Verified on-device: builds, installs, launches without crash; the DX chip
opens the setup sheet; Start enters Hound mode and transmits the grid-call
in the odd slot each cycle with Hunt auto-disabled. Full QSO sequencing
(invite -> QSY -> reply, RR73 -> log) reuses proven primitives but awaits
on-air validation against a live Fox.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds FT4 as a selectable operating mode alongside FT8, driven by a new
ModeProfile descriptor so future modes (e.g. FT2) are a one-entry add
rather than another FT8/FT4 boolean.

- ModeProfile enum: per-mode timing/symbol/protocol params keyed off the
  existing FT8Common.*_MODE ints; GeneralVariables.operatingMode + config
  persistence in DatabaseOpr.
- Encode: parameterize GenerateFT8.generateFt8ByA91 by ModeProfile (FT8
  output unchanged). The prebuilt libft8cn.so exposes no FT4 encode JNI, so
  a shim (cpp/ft4_encode_jni.cpp) in the CMake-built libft8af_usb.so bridges
  to the prebuilt's raw ft4_encode; GenerateFT8 now loads ft8af_usb too.
- Decode: pass the mode's protocol to InitDecoder, tag messages, use the
  mode's RX window.
- Timing: UtcTimer.sequential(utc, slotMillis) generalized (FT8 identical);
  FT8SignalListener/FT8TransmitSignal rebuild their cycle timers on mode
  change; late-start/Costas-clip slack now per-mode; SlotTimerBar
  parameterized by slot length.
- UI: mode pill on the decode page (TxStrip), disabled mid-TX; cycles
  FT8<->FT4 via MainViewModel.setOperatingMode, which retunes the dial
  WITHIN the same band only (never auto-QSY to an untuned band).
- Bands: mode-tagged FT4 dials in bands.txt; pickers filter by mode;
  OperationBand.getModeBandFreq for in-band retune.
- Mode strings (QSL log, SWL log, PSKReporter query, POTA self-spot) now
  derive from ModeProfile.displayName.
- Tests: ModeProfileTest, OperationBand mode parsing/getModeBandFreq,
  UtcTimer.sequential per-mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real-time TX volume: slider takes effect mid-transmission
Add distance, DX location, and a path map to the QSO panel
Persist decode-screen filter across navigation
Add decode-page toggle to clear decodes every cycle
patrickrb added 2 commits June 8, 2026 15:29
Add federated (Google/Facebook/Amazon) POTA sign-in via hosted-UI OAuth
Add FT4 mode (extensible to future modes)
@codecov

codecov Bot commented Jun 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 16.56476% with 1501 lines in your changes missing coverage. Please review.
✅ Project coverage is 6.80%. Comparing base (265c0ea) to head (496f688).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
...in/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaScreen.kt 0.00% 228 Missing ⚠️
...rc/main/kotlin/radio/ks3ckc/ft8us/pota/PotaAuth.kt 0.00% 172 Missing ⚠️
...kotlin/radio/ks3ckc/ft8us/ui/components/TxStrip.kt 0.00% 141 Missing ⚠️
...om/bg7yoz/ft8cn/ft8transmit/FT8TransmitSignal.java 9.52% 133 Missing ⚠️
...in/kotlin/radio/ks3ckc/ft8us/ui/decode/QsoSheet.kt 8.40% 108 Missing and 1 partial ⚠️
.../src/main/java/com/bg7yoz/ft8cn/MainViewModel.java 1.02% 97 Missing ⚠️
...radio/ks3ckc/ft8us/ui/settings/TimeSyncSettings.kt 0.00% 95 Missing ⚠️
...in/radio/ks3ckc/ft8us/ui/pota/PotaActivationMap.kt 0.00% 79 Missing ⚠️
...otlin/radio/ks3ckc/ft8us/ui/pota/PotaOAuthLogin.kt 1.69% 58 Missing ⚠️
.../main/kotlin/radio/ks3ckc/ft8us/pota/PotaClient.kt 0.00% 55 Missing ⚠️
... and 29 more
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##              main    #165      +/-   ##
==========================================
+ Coverage     5.75%   6.80%   +1.05%     
- Complexity     649     708      +59     
==========================================
  Files          266     281      +15     
  Lines        30566   32026    +1460     
  Branches      4763    5056     +293     
==========================================
+ Hits          1758    2180     +422     
- Misses       28667   29702    +1035     
- Partials       141     144       +3     
Files with missing lines Coverage Δ
.../app/src/main/java/com/bg7yoz/ft8cn/FT8Common.java 0.00% <ø> (ø)
...app/src/main/java/com/bg7yoz/ft8cn/Ft8Message.java 42.45% <100.00%> (+2.35%) ⬆️
...c/main/java/com/bg7yoz/ft8cn/GeneralVariables.java 35.14% <100.00%> (+4.20%) ⬆️
...rc/main/java/com/bg7yoz/ft8cn/OwnTxEchoFilter.java 100.00% <100.00%> (ø)
...java/com/bg7yoz/ft8cn/rigs/CatConnectionState.java 100.00% <100.00%> (ø)
...src/main/java/com/bg7yoz/ft8cn/timer/UtcTimer.java 26.37% <100.00%> (+0.81%) ⬆️
...kotlin/radio/ks3ckc/ft8us/pota/model/PotaModels.kt 89.74% <100.00%> (+31.84%) ⬆️
...adio/ks3ckc/ft8us/pskreporter/PskReporterClient.kt 70.94% <100.00%> (+0.25%) ⬆️
.../radio/ks3ckc/ft8us/ui/decode/QsoPathProjection.kt 100.00% <100.00%> (ø)
...o/ks3ckc/ft8us/ui/pota/PotaActivationProjection.kt 100.00% <100.00%> (ø)
... and 40 more

... and 1 file with indirect coverage changes

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Release merge promoting dev to main, rolling up multiple recently-merged features/fixes across POTA upload/auth, FT4 mode support, DXpedition “Hound” mode, QSO path mapping, decode-list UX improvements, and TX-volume correctness.

Changes:

  • Add FT4 as a first-class operating mode (mode profile descriptor, timers, band dials, UI mode pill, PSKReporter/POTA mode strings).
  • Add/finish in-app POTA upload and hosted-UI federated sign-in (OAuth WebView flow, token storage, ADIF builder reuse).
  • Add QSO distance + path map UI, plus decode-list behavior improvements (clear-every-cycle, instant Clear, persistent filter), and TX-volume live-scaling fixes/tests.

Reviewed changes

Copilot reviewed 52 out of 52 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/ui/pota/StripWebViewTokenTest.kt Unit coverage for WebView UA sanitization helper used by hosted-UI OAuth.
ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/ui/pota/BuildActivationAdifTest.kt Robolectric coverage for per-activation ADIF builder, including “no QSOs => empty list”.
ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/ui/map/GeoOutlinesParseTest.kt Coverage for shared GeoJSON outlines parser used by map rendering.
ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/ui/decode/QsoSheetLogicTest.kt Coverage for pure helpers backing QSO sheet distance/grid decode logic.
ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/ui/decode/QsoPathProjectionTest.kt Coverage for pure projection/framing math used by the QSO path inset map.
ft8cn/app/src/test/kotlin/radio/ks3ckc/ft8us/ui/components/ActiveQsoPanelLogicTest.kt Coverage for extracted QSO-panel conversation classification/sorting logic.
ft8cn/app/src/test/java/com/bg7yoz/ft8cn/timer/UtcTimerTest.java Adds tests for slot-length-aware sequential() (FT8 + FT4).
ft8cn/app/src/test/java/com/bg7yoz/ft8cn/OwnTxEchoFilterTest.java Coverage for filtering own-TX loopback echoes out of decodes.
ft8cn/app/src/test/java/com/bg7yoz/ft8cn/ModeProfileTest.java Coverage for FT8/FT4 mode descriptor table and fromId() behavior.
ft8cn/app/src/test/java/com/bg7yoz/ft8cn/ft8transmit/FT8TransmitSignalTest.java Coverage for TX audio helper functions (volume scaling + PCM conversions).
ft8cn/app/src/test/java/com/bg7yoz/ft8cn/Ft8MessageTest.java Coverage for DXpedition combo message formatting edge cases.
ft8cn/app/src/test/java/com/bg7yoz/ft8cn/database/OperationBandTest.java Coverage for FT4-tagged band parsing and mode-specific dial lookup.
ft8cn/app/src/main/res/values/strings_compose.xml Adds strings for mode switching, distance stat, and POTA upload/login UI.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaScreen.kt Adds per-activation “Upload to POTA” flow with login handling and spinners.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaOAuthLogin.kt New hosted-UI OAuth WebView dialog + UA sanitization helper.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/pota/PotaAdifExporter.kt Extracts shared ADIF builder + prevents header-only ADIF exports.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/map/WorldOutlines.kt Extracts shared GeoJSON ring parser; adds US state outlines loader.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/decode/QsoSheet.kt Adds distance stat and a cached-draw path map inset to QSO sheet.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/decode/QsoPathProjection.kt New pure geometry class/constants for map projection/framing.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/decode/DecodeScreen.kt Persistent filter (ViewModel), clear-every-cycle toggle, and instant Clear wiring.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/components/TxStrip.kt Adds mode pill + DX toggle; enforces CQ/HUNT mutual exclusivity/accessibility semantics.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/components/SlotTimerBar.kt Makes slot length configurable (FT8 vs FT4) and aligns slot index computation.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/components/HoundSetupSheet.kt New setup dialog for DXpedition Hound mode parameters.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/components/FT8USIcons.kt Adds AutoClear icon for decode “clear every cycle” toggle.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/components/FrequencyPickerSheet.kt Filters band/dial picker by operating mode (FT8 vs FT4).
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ui/components/ActiveQsoPanel.kt Extracts QSO log builder; removes decoded-loopback TX branch reliance.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pskreporter/PskReporterClient.kt Uses current operating mode name in PSKReporter query.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pota/PotaClient.kt Adds authenticated ADIF upload and job polling helpers.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/pota/PotaAuth.kt New Cognito SRP + hosted-UI OAuth auth manager with encrypted refresh-token storage.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/FT8USApp.kt Wires mode pill/timer, DX (Hound) setup sheet, and mode switching UX.
ft8cn/app/src/main/kotlin/radio/ks3ckc/ft8us/ComposeMainActivity.kt Forwards live TX volume updates to native USB output path; applies loaded operating mode.
ft8cn/app/src/main/java/com/bg7yoz/ft8cn/wave/UsbAudioNative.java Adds native live TX volume setter API.
ft8cn/app/src/main/java/com/bg7yoz/ft8cn/wave/UsbAudioDevice.java Seeds native volume and applies live scaling in Java fallback USB output loop.
ft8cn/app/src/main/java/com/bg7yoz/ft8cn/timer/UtcTimer.java Adds slot-length-aware sequential() and routes legacy sequential() through current mode.
ft8cn/app/src/main/java/com/bg7yoz/ft8cn/rigs/TrUSDXRig.java Moves TX volume to live per-chunk scaling in truSDX path.
ft8cn/app/src/main/java/com/bg7yoz/ft8cn/OwnTxEchoFilter.java New filter utility for dropping own-TX loopback decodes + diagnostics.
ft8cn/app/src/main/java/com/bg7yoz/ft8cn/ModeProfile.java New FT8/FT4 mode descriptor table and encode dispatch.
ft8cn/app/src/main/java/com/bg7yoz/ft8cn/MainViewModel.java Adds mode LiveData + mode switching; decode list snapshot publishing; echo filter; clear-every-cycle logic.
ft8cn/app/src/main/java/com/bg7yoz/ft8cn/GeneralVariables.java Adds operatingMode/currentMode, clear-every-cycle flag, and Hound mode globals.
ft8cn/app/src/main/java/com/bg7yoz/ft8cn/ft8transmit/GenerateFT8.java Adds FT4 encode binding + mode-aware waveform generation.
ft8cn/app/src/main/java/com/bg7yoz/ft8cn/ft8transmit/FT8TransmitSignal.java Implements MODE_STREAM chunked AudioTrack playback, mode-aware timers, Hound mode handler, and late-start math fixes.
ft8cn/app/src/main/java/com/bg7yoz/ft8cn/Ft8Message.java Fixes DXpedition combo report formatting (no “--18”; “+0” for zero).
ft8cn/app/src/main/java/com/bg7yoz/ft8cn/ft8listener/FT8SignalListener.java Makes listener timer mode-aware and rebuildable; passes mode flag into decoder init.
ft8cn/app/src/main/java/com/bg7yoz/ft8cn/database/OperationBand.java Adds per-mode band entries + mode-specific dial lookup + mode filtering.
ft8cn/app/src/main/java/com/bg7yoz/ft8cn/database/DatabaseOpr.java Persists mode names in SWL messages; loads clear-every-cycle + operatingMode config keys.
ft8cn/app/src/main/cpp/usb_audio_capture.cpp Adds live TX gain scaling and JNI setter for USB-direct output drain loop.
ft8cn/app/src/main/cpp/ft4_encode_jni.cpp New JNI shim bridging FT4 encode into the prebuilt DSP library.
ft8cn/app/src/main/cpp/CMakeLists.txt Imports prebuilt libft8cn.so and links ft8af_usb against it (FT4 encode shim).
ft8cn/app/src/main/assets/bands.txt Adds FT4 dial entries tagged by mode.
ft8cn/app/build.gradle Adds AWS Cognito SRP dependency + AndroidX security-crypto for token storage.
CLAUDE.md Documents worktree-based workflow and test expectations for contributions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 367 to +370
public void afterDecode(long utc, float time_sec, int sequential
, ArrayList<Ft8Message> messages, boolean isDeep) {
if (messages.size() == 0) return;//no messages decoded, don't trigger action
, ArrayList<Ft8Message> decoded, boolean isDeep) {
if (decoded.size() == 0) return;//no messages decoded, don't trigger action

Comment on lines +161 to +163
// Volume is baked into the TX samples per-cycle (see playFT8Signal / playViaUsbAudio),
// so there is no live mid-cycle setVolume() observer here: a volume change takes effect
// on the next cycle. This matches the ALC auto-volume model (MeterProtectionController).
Two pushes to dev landed 19s apart (merges of #161 and #163) and both
ran the "Publish AAB to Play Internal track" step concurrently. Google
Play permits only one active edit per app, so the second run's edit was
invalidated mid-upload and the job failed with "This edit has expired,
please create a new Edit."

Add a workflow-level concurrency group so all release-publishing runs
(pushes to main/dev and v* tags) share a single "play-publish" queue and
never overlap on the Play API. cancel-in-progress is false so an
in-flight upload finishes instead of being killed. PRs and feature-branch
pushes get a unique per-run group and are never queued.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
patrickrb and others added 5 commits June 8, 2026 16:02
The TX strip packed six controls — status, mode, frequency, DX, HUNT, CQ,
and the TX-slot toggle — into a single non-wrapping Row with
SpaceBetween. On a Pixel 8 in portrait (~411 dp) their combined intrinsic
width overflowed the right edge, clipping the CQ and TX1/TX2 buttons off
screen so CQ couldn't be tapped. The row got tighter once the FT4 mode
pill (#163) and DX/Hound toggle (#162) were added.

Convert the strip to a FlowRow (matching the ExperimentalLayoutApi
FlowRow already used in ActiveQsoPanel) so overflow controls wrap onto a
second line instead of running off the edge. The six pills are now direct
FlowRow children, each centered within its line via Modifier.align, with
8.dp horizontal and 6.dp vertical spacing. In portrait this lays out as
status/mode/frequency/DX on line one and HUNT/CQ/TX on line two; on wider
screens it stays a single line. The status text is capped to one line so
a long localized label can't balloon its row.

No control logic, colors, or callbacks changed. This is a declarative
layout change with no extractable decision/geometry logic to unit-test
(the project's tests are JUnit4 + Truth logic tests; Compose layout is
verified on-device). Verified on a Pixel 8 in portrait: all six controls
visible and tappable, CQ no longer clipped. Existing unit suite still
passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FT2 is the new ultra-fast HF digital mode from ft2.it (IU8LMC). It is
structurally identical to FT4 — 4-GFSK, four Costas sync blocks, 87 data
symbols, 77-bit payload, LDPC(174,91) — but runs at double the FT4 baud:
0.024s symbol period (NSPS 288 @12kHz), ~41.7Hz tone spacing, ~167Hz BW,
3.8s T/R cycle, ~2.52s audio. Confirmed against Decodium's FtxFt2Stage7.cpp.

TX + mode plumbing (mirrors the FT4 PR #163):
- FT8Common.FT2_MODE + ModeProfile.FT2 (one-entry add). Because FT2's tones
  are bit-identical to FT4, encode() reuses ft4Encode and synth runs at the
  FT2 symbol period — no new encode code.
- bands.txt FT2-tagged dials (PROVISIONAL: FT8 sub-band placeholders pending
  the official ft2.it/HamPass list); OperationBand parser resolves the mode
  tag via ModeProfile.displayName so future modes need no new branch.
- Mode pill cycles FT8->FT4->FT2 (iterates ModeProfile entries); PSKReporter
  mode string derives from displayName.

RX — parallel from-source decoder (the prebuilt libft8cn.so has no FT2):
- Adds FTX_PROTOCOL_FT2 to the in-tree kgoba ft8_lib (pinned at 6f528128,
  the same commit the prebuilt was built from): constants.h period/slot,
  monitor.c symbol-period switch, decode.c FT4 branches broadened to FT2
  (shared 4-Costas/105-symbol/XOR layout).
- ft2_decode_jni.cpp: FT2 decode JNI wrapper (adapted from the ft8_decoder.cpp
  reconstruction) with distinct *Ft2 entry points and protocol fixed to FT2.
- CMake compiles the from-source decode slice + wrapper into libft8af_usb.so
  with -fvisibility=hidden so its symbols stay internal and never clash with
  the prebuilt's exported copies; FT8/FT4 keep using the prebuilt unchanged.
- FT8SignalListener routes the decode loop to the FT2 backend when
  ModeProfile.usesFt2Decoder(); FT8/FT4 paths untouched.

This newly tracks the vendored kgoba ft8_lib (previously untracked reference,
"reconstruction in progress") because the FT2 build now compiles it.

Tests: ModeProfile/OperationBand/UtcTimer FT2 cases. Verified on a Pixel 8:
unit tests pass, native decoder links across all 4 ABIs, and a native FT2
round-trip (ft4_encode -> GFSK@0.024s -> FT2 decode) recovers the message
on-device (Block size=288, score 40).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FT8 needs the device clock within ~1 s of UTC, but the only sync paths
today fail in the field: NTP auto-sync needs internet, and the legacy
+/-7.5 s spinner lives only in the dead ConfigFragment (the Compose
Settings UI had no time control at all).

Add a "Time Sync" settings category with a +/- stepper (+/-0.1 s and
+/-0.5 s, range +/-2.0 s) plus a Reset, driving UtcTimer.delay -- the
single offset every RX window, TX start, and the slot-timer bar reads
through. The correction is persisted under a new "timeCorrectionMs"
config key and re-applied to UtcTimer.delay at startup, so an offline
nudge survives a relaunch.

A suggestion card surfaces the most recent cycle's average decode DT
(mainViewModel.mutableTimerOffset) -- the only time reference available
offline -- with one-tap apply via suggestedCorrectionMs().

Decision/format logic is extracted into pure functions in
TimeCorrection.kt (clamp/step/suggested/format) and unit-tested in
TimeCorrectionTest.kt, keeping the Composable a thin wrapper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CAT connection state previously surfaced only as transient Toasts, with no
persistent indicator. Bluetooth in particular usually only connects on the
second attempt: the first socketConnect() fails and silently drops back to
disconnected, and a later CAT command auto-retries — so it works eventually,
but the operator gets no signal in between.

Add an always-visible status chip to the bottom TX strip (grey=disconnected,
amber pulsing=connecting, green=connected, red=error) that is tappable to
re-trigger the connection, turning the second-attempt friction into one tap.

- New CatConnectionState enum exposed as LiveData on MainViewModel, driven from
  the rig-state callbacks plus a new onConnecting() default-method callback
  (no existing implementer changes). BluetoothRigConnector.socketConnect() and
  CableConnector.connect() fire onConnecting(), covering the auto-retry path.
- MainViewModel.reconnectRig() reuses the connector connect() path on tap.
- CatStatusChip with pure, tested catChipVisuals()/shouldShowCatChip(); chip is
  hidden for VOX/audio-only setups.
- Strings added across all 6 locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two issues flagged by Copilot on the dev->main release PR (#165):

1. MainViewModel.afterDecode() returned early on a zero-decode slot without
   clearing mutableIsDecoding. beforeListen() sets it true every cycle, so a
   silent slot left the spectrum-display decoding marker stuck on until a later
   non-empty cycle. Route all three afterDecode() exit paths through a new pure
   helper decodingMarkerAfterPass() so they stay in agreement, and cover it with
   DecodingMarkerTest.

2. FT8TransmitSignal's constructor comment claimed TX volume is baked per-cycle
   and only takes effect next cycle. The live-TX-volume change (#155) made the
   MODE_STREAM AudioTrack loop re-read volumePercent per chunk and the USB-direct
   path apply gain live via setTxVolume, so the comment was stale. Updated it to
   describe the live behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
patrickrb and others added 15 commits June 8, 2026 17:07
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
- PskReporterSender: drop spots with an unknown/corrupt signalFormat instead of
  mislabelling them as FT8 (ModeProfile.fromId falls back to FT8); restores the
  prior drop-on-unknown behavior while still supporting FT2.
- FT8SignalListener / ReBuildSignal: wrap the native loadLibrary calls in
  try/catch (mirroring GenerateFT8) so class init doesn't crash when native libs
  aren't on the path (e.g. JVM unit tests).
- monitor.c: include <math.h> (sinf/log10f) and common.h (M_PI fallback) so the
  vendored source builds on toolchains where <math.h> doesn't define M_PI.
- Add ft8_lib/fft/COPYING (KISS FFT BSD-3-Clause) referenced by the kiss_fft
  headers but previously missing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two issues raised on PR #170:

1. ERROR was unobservable. A failed Bluetooth connect calls onRunError()
   (ERROR) immediately followed by socketDisconnect() -> onDisconnected(),
   which overwrote the state back to DISCONNECTED, so the chip never stayed
   red. Track a synchronous catConnectionState mirror (postValue/getValue
   would race across the two callbacks) and add CatConnectionState.afterDisconnect()
   which preserves ERROR until the next connect attempt (onConnecting) or a
   success clears it.

2. showCatChip read GeneralVariables.controlMode, which isn't observable Compose
   state, so switching VOX <-> CAT/RTS/DTR in Settings wouldn't update the chip
   until an unrelated recomposition. Add GeneralVariables.mutableControlMode
   LiveData, post it from setControlMode() and the connect paths that force CAT,
   and derive showCatChip from the observed value in FT8USApp.

Adds unit tests for afterDisconnect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix stuck decoding marker on silent slots; correct TX-volume comment
Serialize Play publishing to prevent edit-expired races
Wrap TX strip controls so CQ isn't clipped in portrait
Apply-suggested computed suggestedCorrectionMs() off the live
UtcTimer.delay while the rest of the screen reads the local correctionMs
state. If NTP sync updates UtcTimer.delay asynchronously while the Time
Sync screen is open, the suggestion would be applied against a different
base than what the UI shows. Use correctionMs so the suggestion is
consistent with the displayed value (addresses Copilot review on #169).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add tappable CAT connection status chip to TX strip
Add a compact equirectangular map to POTA activations that frames the
operator plus every plottable contact, with a dashed line out to each.
It generalizes the QSO-distance map's projection (QsoPathProjection) from
two endpoints to N contacts, reusing the same padding, min-span floor, and
antemeridian short-path normalization.

- PotaActivationProjection: pure N-point framing math, unit-tested without
  a Canvas (parity with the 2-point QSO projection, short-path lon, min-span
  clamp, centering).
- buildActivationMapData: turns an activation's QSOs into map inputs; drops
  contacts with no/unparseable grid, sources the operator from the first
  QSO's my_gridsquare (so historical activations plot where the op was) with
  a fallback to the live grid, returns null when nothing is plottable.
- PotaActivationMap: drawWithCache Compose map (land + US state outlines,
  operator/contact dots, operator label).

Rework History: replace the inline expand/collapse row with a tap-through
ActivationDetailScreen (back nav, park pills, contacts map, Share-ADIF /
Open-pota.app actions, full QSO list). The map also renders live in the
Activate tab.

Plumbing: PotaActivationDao now selects my_gridsquare; PotaQso gains a
myGrid field. New strings pota_contacts_map / pota_back across all locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve PotaScreen.kt conflict between dev's direct upload-to-POTA feature
(Cognito/OAuth sign-in + inline history-row upload button) and this branch's
tap-through ActivationDetailScreen. Since the detail screen replaces the
inline row and PotaScreen unmounts HistoryTab when it shows, the upload
button and login/OAuth dialogs move into ActivationDetailScreen; HistoryTab
becomes pure navigation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add manual time correction for offline operating
- PotaActivationMap: hoist the dashed-line PathEffect out of the per-contact
  draw loop into the drawWithCache cache phase to avoid per-frame allocation.
- ActivationDetailScreen: fall back to the live grid only for active
  activations, so an in-progress activation map shows before the first QSO
  carries my_gridsquare, while finished activations stay null (no misplacement
  from a stale current grid).
- PotaActivationProjection: fix a duplicated-word KDoc typo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POTA: activation contacts map + tap-through history detail
@patrickrb patrickrb changed the title Release: dev → main (PRs #151–#164) Release: dev → main (PRs #151–#172) Jun 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants