Skip to content

Release: staging → main (open-source DSP, iOS app, Android Auto, map redesign, Kotlin 2) - #465

Merged
patrickrb merged 297 commits into
mainfrom
staging
Jul 8, 2026
Merged

Release: staging → main (open-source DSP, iOS app, Android Auto, map redesign, Kotlin 2)#465
patrickrb merged 297 commits into
mainfrom
staging

Conversation

@patrickrb

Copy link
Copy Markdown
Owner

Promote stagingmain.

This is a large release — 296 commits across ~70 feature/fix PRs (#275#464) since the last main cut (#304). The headline is a fully open-source FT8/FT4 DSP core, a new native iOS app, an Android Auto experience, a redesigned map, and the Kotlin 2 / 16 KB page-size platform bump — plus a deep stack of decode, USB/audio, and rig-control fixes.


🔊 Decode & DSP core

📻 Rig control, audio & USB

🗺️ Map redesign

🚗 Android Auto

🍎 iOS app (new native port)

A ground-up Swift/SwiftUI app reusing only the C ft8_lib core:

📝 QSO workflow & UI

📇 Logging & integrations

🏗️ Platform, build & deprecations

⚙️ CI, coverage & docs


Rolls up the intermediate dev → staging promotions (#374, #391, #397, #452, #464).

🤖 Generated with Claude Code

patrickrb and others added 30 commits June 18, 2026 12:40
iOS: Add full app shell (Phase 1) — 6-tab UI with mock data
…rfall

Wire FT8AFKit engine into the Phase 1 UI shell:
- AudioCaptureService: AVAudioEngine mic capture with 12 kHz downsampling
- FFTProcessor: Welch-averaged vDSP FFT for waterfall power spectrum
- LiveEngine: coordinator running decode + waterfall pipelines concurrently
- WaterfallCanvas/SpectrumStrip: replace mock data with live engine output
- AppState: add spectrum field, start decode list empty
- project.yml: add NSMicrophoneUsageDescription, Accelerate framework

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- FFTProcessor: window each segment in place via the buffer base pointer,
  dropping the redundant whole-buffer Hann pass and the per-segment Array
  allocation (output is numerically identical).
- AudioCaptureService: guard `converter` reads/writes with the existing lock
  (it was written on the main thread in stop() and read on the RT audio
  thread); reuse a preallocated output buffer and push via UnsafeBufferPointer
  to avoid per-callback heap allocations on the RT thread.
- SlotAccumulator: add UnsafeBufferPointer<Float> push overload (array push now
  delegates to it) and mark @unchecked Sendable (fully lock-protected).
- LiveEngine: reset waterfall.isLive on stop() via a weak AppState reference;
  pass @mainactor @sendable update closures into the background loops so they
  no longer capture/reference AppState directly.
- Tests: cover the new pointer-push overload (parity with array push + empty).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDu3dtuN89JsRqxFsq1ow9
iOS Phase 2: live audio + real-time decode + scrolling waterfall
…istence

Wire the remaining engine capabilities into the iOS UI:

- Add TxPlayerService: plays FT8 TX waveforms through speaker via
  AVAudioPlayerNode attached to the shared AVAudioEngine, with
  12 kHz → hardware sample rate conversion
- Add QsoLogStore: JSON file persistence for QSO records in the app
  documents directory, loaded on startup and saved after each QSO
- Expand LiveEngine: owns QsoEngine + TxPlayerService, feeds decoded
  messages to the auto-sequencer, schedules TX at slot boundaries,
  exposes callCQ/stopTx/answerStation/toggleHunt/toggleSlotParity
  for UI control
- Expose AudioCaptureService.audioEngine so TxPlayerService can
  attach its player node to the same engine instance
- Wire TxStrip buttons: HUNT toggles hunt mode, CQ/STOP starts or
  stops CQ calling, TX1/TX2 toggles slot parity
- Wire QsoSheet "Call" button: starts answering the tapped station
- Wire ADIF export: generates ADIF via Adif.export() and presents
  a share sheet
- Update logbook to start empty (loaded from disk) instead of mock data
- Remove phase footer notes from RadioAudioSettings
- Update version string from "1.0.0 (Phase 1)" to "1.0.0"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
TxStrip and QsoSheet previously pulled LiveEngine from the SwiftUI
environment directly, which caused the button row to not render if the
environment value wasn't propagated (e.g. into presented sheets).

Switch to a closure-based approach: DecodeScreen and WaterfallScreen
(which have LiveEngine in their environment) pass action closures down
to TxStrip and QsoSheet. This removes the fragile environment
dependency from leaf views and ensures the buttons always render.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…self)

@Environment(Type.self) with @observable fatally crashes if the type
isn't found in the environment, silently killing the entire screen
view (including the TxStrip button row).

Fix: store the engine reference on AppState as an optional property
(appState.engine) instead of injecting it as a separate environment
value. Every view already has AppState in the environment, so
appState.engine?.callCQ() etc. is always safe — nil before the engine
starts, non-nil once .task wires it.

- Remove .environment(engine) from FT8AFApp
- Add engine: LiveEngine? to AppState, set in .task at startup
- DecodeScreen / WaterfallScreen: use appState.engine? instead of
  @Environment(LiveEngine.self)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two bugs fixed:

1. TxPlayerService crashed on launch by calling engine.connect() with
   the outputNode's format which reports 0 Hz on Simulator before
   hardware is ready. Fix: defer node attachment to first play() call
   via ensureAttached(), and use mainMixerNode.outputFormat instead
   of outputNode.outputFormat.

2. TxStrip (HUNT / CALL CQ / TX1 buttons) was rendered inside the
   screen content VStack but hidden underneath the overlaid custom
   tab bar ZStack. Fix: move TxStrip out of individual screens and
   into AppTabView's bottom chrome VStack, above SlotTimerBar and
   TabBarView. Only shown on Decode and Waterfall tabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…eases

The repo holds three FT8 clients sharing one C DSP core, but all CI was
Android-only and fired on every push/PR with no path filtering — an iOS-only
change ran the full Android NDK build, and iOS/desktop had no CI at all.

Split into per-project pipelines that only run when their files (or the shared
core at ft8af/app/src/main/cpp/**) change:

- android.yml: renamed from build-release.yml, folds in static-analysis.yml as
  a job, adds a `detect` (dorny/paths-filter) gate and an always-run
  `android-gate` aggregator. All existing behavior (NDK retry, signing, tag
  derivation, GitHub Release, Play internal publish, play-publish concurrency)
  is preserved; job names test/instrumented/build are unchanged.
- ios.yml (new): swift test on FT8AFKit + unsigned iOS-Simulator build
  (xcodegen generate -> xcodebuild) on macos-14; `ios-gate` aggregator.
- desktop.yml (new): Tauri compile-check on Windows/macOS/Linux for PRs and
  non-release pushes; on main / desktop-v* tags builds native bundles on all
  three OSes and publishes a namespaced desktop-v* / desktop-dev-N release via
  tauri-action; `desktop-gate` aggregator.

Behavior model: PRs run only the affected project's checks; dev pushes build
only affected platform(s) as prereleases; main pushes / tags build everything
(one release per platform). native-tests.yml stays always-on as the shared-core
gate; main-gate.yml and the discord workflows are untouched.

Because path-filtered jobs can be skipped, the *-gate jobs always run and are
the intended required status checks (a skipped required check otherwise leaves
branch protection stuck "pending"). Branch protection must be updated to require
android-gate / ios-gate / desktop-gate instead of the inner jobs — documented in
the PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, TxStrip expansion, active QSO panel, map/logbook/QsoSheet enrichment, TX glow + celebration

Close the UI/UX gap between iOS and Android:
- Settings persistence via UserDefaults (survives app relaunch)
- Decode screen: row color-coding (CQ=orange, for-me=pink, worked=cyan),
  time dividers between slots, auto-scroll, compact mode toggle
- TxStrip: expand/collapse with volume slider + step buttons, tappable
  frequency label → FrequencyPickerSheet, CAT status chip
- Active QSO panel: floating progress panel with 5-step stage dots,
  expand/collapse, TX message display
- QSO sheet: distance/azimuth via Haversine, QSO sequence visualizer
- Logbook: search bar, swipe-to-delete, tap-to-edit sheet, file-based
  ADIF export (.adi attachment)
- Map: status-based marker colors, great circle lines to selected
  station, operator diamond marker, enhanced popup with SNR + distance
- TransmitGlow: animated red/orange border during TX
- QsoCelebration: confetti particle burst on QSO completion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…uild

Address Copilot review on PR #296:
- android-gate / ios-gate / desktop-gate now depend on (and check) their
  detect/version prerequisites, not just the build/verify/test legs. A
  failed `detect` (or desktop `version`) skips the downstream jobs, and a
  skip alone was being read as "path-filtered, nothing to do" — letting a
  broken workflow report success on the required check. Each gate now fails
  if any prerequisite result is failure/cancelled; a genuine path-skip
  (detect succeeded, run=false) still passes.

Also fix the iOS verify job, which failed to compile: ft8_lib's `fmtmsg`
collided with the POSIX `fmtmsg(long, ...)` from <fmtmsg.h>, visible when
the shared C core is built under the macOS/Apple SDK (FT8AFKit swift test).
Rename the unused helper to ft8_fmtmsg (no call sites anywhere in the repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDu3dtuN89JsRqxFsq1ow9
…roject

The ios.yml simulator-build leg failed with "future Xcode project file
format (77)": XcodeGen 2.45.4 defaults the generated pbxproj to objectVersion
77 (Xcode 16-only), but the macos-14 runner's Xcode 15.4 — matching the
project's pinned xcodeVersion 15.0 — can't read it. Pin objectVersion 56,
which Xcode 15 opens and Xcode 16 still accepts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDu3dtuN89JsRqxFsq1ow9
XcodeGen 2.45.x always emits the pbxproj in Xcode 16's object format
(objectVersion 77); Xcode 15.4 on the macos-14 runner can't open it
("future Xcode project file format (77)"). Pinning objectVersion via
project.yml had no effect (not an honored XcodeGen option), so build the
simulator leg on macos-15 / Xcode 16 instead. The iOS 17 deployment target
still builds under the newer toolchain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDu3dtuN89JsRqxFsq1ow9
…ion log, POTA enrichment

- Logbook stats tab with band donut chart, SNR sparkline, grid heatmap, award progress
- Azimuthal equidistant projection with range rings, compass bearings, projected continents
- Map dual projection toggle (equirectangular/azimuthal) with zoom controls
- Waterfall functional NR/MSG toggles and update counter
- Active QSO panel with TX/RX conversation log and auto-scroll
- QSO sheet auto-open when CQ answered, auto-close after completion
- POTA activate tab with park input, session card, progress bar, elapsed timer
- POTA history tab showing past park references
- Engine logs TX/RX messages to conversation panel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ode filters, waterfall labels

- Logbook Awards tab with DXCC Mixed, WAS, WAZ, VUCC, DXCC Challenge progress cards
- Multi-format export sheet (ADIF, CSV, Cabrillo, JSON) with date range filtering
- Logbook row enrichment: freq display under band pill, SNR color-coding
- Toast notification overlay system with auto-dismiss for user feedback
- Operator identity card in Settings with edit sheet
- About screen with version info, debug log viewer, credits
- Blocked callsigns settings with add/remove and decode filter integration
- Decode filters: New DXCC shows unworked prefixes, Needed shows unworked CQ stations
- CQ POTA filter expanded for VE-/DL- park references
- Waterfall message frequency labels drawn on canvas when MSG toggle is on
- TX frequency marker line on waterfall

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The ubuntu desktop build failed in libudev-sys's build script:
"Package libudev was not found in the pkg-config search path." The
serialport crate (CAT rig control) needs libudev on Linux, which the apt
step wasn't installing. Add libudev-dev alongside the existing Tauri/GTK
system deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDu3dtuN89JsRqxFsq1ow9
The Settings operator card displayed the rig as a single letter (e.g. c)
in release builds. It was reading baseRig.javaClass.simpleName, but R8
obfuscates the com.k1af.ft8af.rigs package (no keep rule), so the class
name collapses to a meaningless short token. Selecting a different radio
or rebooting did not help because every rig class is obfuscated the same way.

Switch the card to GeneralVariables.myRigName, the user-selected model name
from RigNameList that MainViewModel.connectRig already computes for exactly
this reason (PSKReporter + display). Extract the resolution into a testable
internal resolveRigDisplayName() helper and cover it with unit tests.

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

- Spectrum strip tap/drag to set TX frequency with live Hz readout
- TX frequency display chip in waterfall info bar
- ADIF import with duplicate detection and toast feedback
- Full FT8 message text line in decode rows (non-compact mode)
- Advanced settings screen (PTT delay, TX delay, late-start tolerance)
- QsoSheet TX message banner (TX NOW/TX NEXT)
- Slot timer bar with parity-based colors (even=orange, odd=blue)
- FilterChip amber border on selection
- Settings persistence for timing fields and advanced link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ectrum width

- Auto-sequence toggles: Hunt CQ, Auto-call follow, Early decode, Auto-CQ after QSO
- TX safety pickers: Watchdog timeout (off/5-95min), Stop-after limit (off/1-30)
- QRZ lookup button in QsoSheet opens station profile in browser
- Clear-all button in decode screen top bar
- Spectrum width picker (2500-5000 Hz) in Radio & Audio settings
- Enabled bands manager with per-band toggles and FT8 frequencies
- All new settings persisted via UserDefaults

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Haptic feedback on TxStrip action buttons (medium/light impact)
- Success haptic on QSO celebration confetti
- Selection haptic on frequency picker band selection
- Animated splash screen with compass rose, progress bar, version display
- QSO path equirectangular mini-map in QsoSheet showing great circle path
  between operator and remote station with continent outlines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fix rig name showing obfuscated single letter on operator card
With libudev-dev in place the Linux build now gets past serialport and
fails next in alsa-sys: "Package alsa was not found in the pkg-config
search path." The audio crate (cpal/rodio) needs ALSA on Linux. Add
libasound2-dev to the apt step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDu3dtuN89JsRqxFsq1ow9
The Linux desktop build hung indefinitely on "Install Linux dependencies"
while the macOS/Windows builds finished in minutes. Two fixes:

- ubuntu-latest is now Ubuntu 24.04, which dropped libappindicator3-dev.
  Switch to libayatana-appindicator3-dev (Tauri's current Linux prereq).
- Harden apt against the classic CI hangs: DEBIAN_FRONTEND=noninteractive
  (suppress debconf prompts -y misses) and -o DPkg::Lock::Timeout=600 (wait
  up to 10 min for the dpkg lock instead of forever, then fail loudly).
  Also --no-install-recommends to keep the install lean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI: per-project path-filtered workflows + per-platform releases
The map canvas was locked to a 2:1 (standard) / 1:1 (azimuthal) box centered
in a stacked Column, leaving dead-space bands above and below it on a tall
phone. Make the map fill the whole tab edge-to-edge and move the chrome
(grid/count chip, PSK + STD/AZ toggles, zoom controls, selected-station card)
into floating overlays on top of it.

New pure EquirectViewport owns the canvas-fit + pan math, keeping
equirectProject untouched (still normalized [-1,1]). COVER scales the 2:1 world
to fill the canvas (overflowing on the long axis, so a portrait phone shows the
full latitude range and pans east-west). clampPan floors the allowed pan at 0
on an axis where the map is smaller than the canvas — the load-bearing fix vs.
the old size*(scale-1)/2 formula, which assumed the unzoomed map exactly filled
the canvas and would otherwise let it be dragged into empty space.

drawWorldLand / drawEquirectGrid take the viewport and use inline float math
(no per-vertex Offset allocation, since they run every animation frame).

Tests: MapViewportTest covers COVER/FIT scaling, projection centering + pan,
and clampPan on the overflow and underflow axes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every decoded station was drawn as the same small circle with an always-on
text label, which overlapped into an unreadable smear in a busy opening, and
the only state cue was colour. Give each station type a distinct glyph, tint it
by SNR, and only draw the callsign label when zoomed in or selected.

New pure markerStyleFor() decides (shape, fill, radius, showLabel) from a
station's state — triangle = CQ, diamond = calling-me, donut = worked, dot =
other; PSK receivers stay squares. snrTint() fades weak signals back and leaves
strong ones near-opaque (selected markers ignore the fade). Labels gate on
isSelected || zoom >= LABEL_ZOOM_THRESHOLD. The canvas is now a thin renderer:
drawMarkerShape() switches on the shape; both the standard and azimuthal canvases
and the PSK-spot loops go through it.

Shape matters because Signal (calling-me) and StatusWorked share a colour — the
diamond vs donut is what tells them apart.

Tests: MarkerStyleTest (13 cases) covers shape priority (to-me > worked > CQ >
dot), the SNR alpha ramp + clamping, and the label declutter rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lines used to appear only for the selected marker. GridTracker-style, draw a
persistent line from the operator to the station we're actively calling and from
any station whose decode is directed at us, so the active exchange is visible at
a glance.

New pure buildConnectionLines() takes the decoded stations (via a small
StationLine interface that StationMarker implements) and the TX target callsign,
and returns the lines to draw: a SOLID line to the TX target when it's been
decoded (we can only place a callsign we have a grid for — an unmatched target
draws nothing), and a DASHED line from every isToMe station, de-duplicated
against the target. MapScreen resolves the target from
ft8TransmitSignal.mutableToCallsign (null for CQ/idle). Both canvases draw each
line through the same projection as the markers, so lines pan/zoom with the map;
the azimuthal canvas skips lines whose endpoint falls off the disc.

Tests: ConnectionLinesTest (8 cases) covers matched/unmatched target, dashed
calling-me lines, target-also-calling-me de-dup, CQ/null/blank target, and the
empty case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The PSK overlay was hardwired to a fixed 1-hour window, the rig's current mode,
all bands, and "who heard me". Add a filter sheet so the operator can pick the
time window, band, mode, and direction.

- New pure PskFilter model: Band enum + freqHzToBand (client-side band cull,
  applyPskBandFilter), PskMode (incl. CURRENT = follow the rig), PskDirection,
  and the time-window presets (15m/1h/3h/6h/24h).
- PskReporterClient.fetchSpotsForMe gains mode + byReceiver params. mode overrides
  the queried mode (null = current). byReceiver flips the query from
  senderCallsign=me (plot the receiver that heard us) to receiverCallsign=me (plot
  the sender we heard, using senderLocator). The cooldown / 429-503 back-off is
  unchanged. PskReporterSpot is now neutral (callsign/grid/lat/lon) since the
  plotted station depends on direction.
- MapScreen holds the filter as saveable state, shows a floating filter sheet
  (chips for each axis), and re-runs the fetch when it changes — force-once so the
  change isn't swallowed by the 5-min cooldown, with band applied client-side.

Direction is scoped to two single-callsign queries (heard-me / I-heard); the
unbounded "all stations in area" query is deferred (it trips PSK Reporter's
rate-limit on a 5-min poll). "I heard" depends on senderLocator being present in
the report.

Tests: PskFilterTest (band classification, boundaries, client-side cull, time
labels); PskReporterClientTest extended for the mode param in the query and the
byReceiver query/plot-sender path; existing cooldown/back-off tests still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The class doc said the world was 4*baseUniform wide by 2*baseUniform tall and
described baseUniform as "px per normalized y half-unit", but the code computes
worldPxW = 2*baseUniform and worldPxH = 1*baseUniform. Correct the doc to the
actual model (baseUniform = full world height in px; world is 2*baseUniform by
baseUniform), addressing the review comment on #299. No behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
patrickrb and others added 23 commits July 6, 2026 19:40
* Fix CAT chip falsely turning red after FT8 transmit

The liveness watchdog suppresses probing during TX, so lastRigResponseMs
freezes for the whole over. An FT8 transmit is 12.64s — longer than the
8s stale timeout — so the first tick after TX ends judged the pre-TX
timestamp as stale and flipped the chip to ERROR (sticky red) even though
the rig was fully responsive. FT4 (~4.48s) stayed under the timeout, so
this only manifested on FT8.

Re-arm lastRigResponseMs on the TX->RX edge so the quiet window restarts
from the end of transmit, giving the probe sent that same tick time to
reply before any staleness judgement. Edge detection lives in a pure
CatLiveness.shouldRearmAfterTx predicate with unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B1egpdNWafvAksJCn1tMA

* Sample wall clock once per liveness tick

Address review feedback: catLivenessTick() read System.currentTimeMillis()
twice (re-arm and staleness check). Capture a single nowMs at the top of the
tick and reuse it for both, so a clock change mid-tick can't skew the
comparison and the tick is easier to reason about.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Release: promote dev → staging (Android Auto status → Play internal)
Switch the car screen from PaneTemplate-only to direct Surface rendering
via MapWithContentTemplate (car-app API level 5+). The 1 Hz tick now
redraws the Canvas directly instead of calling invalidate(), which
eliminates the host dimming/flashing on every countdown update.

The surface draws a world map (land outlines from WorldOutlines) with
decoded-station markers and an operator dot, covered by a 75% black
overlay, then status text (headline, TX message, sequence step, slot
countdown, band/mode, POTA activation line) and a TX indicator dot.

Hosts below API level 5 fall back to the original PaneTemplate path
(minCarApiLevel stays at 1 for backward compat).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The POI category added in the previous commit triggers Android Auto's
driving restrictions, blocking the entire screen while in motion. The
IOT category has no such restriction.

MapWithContentTemplate (surface-based rendering) requires POI or
Navigation category, so it can't be used with IOT while driving.
Remove the surface rendering code and fix dimming differently: the tick
now invalidates only at slot boundaries (every 15s for FT8) instead of
every second. LiveData observers still trigger immediate refreshes for
real state changes.

POTA activation line is kept — added as a 4th PaneTemplate row.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Render a 480x240 equirectangular world map as the PaneTemplate hero
image. Shows the operator's position (orange dot) and, during an active
QSO, a line to the partner station (cyan dot). The bitmap is built with
Canvas from WorldOutlines land data — no map templates, no POI category,
works while driving.

The image is cached by (opGrid, partnerCall, partnerGrid) so
onGetTemplate() skips re-rendering when nothing moved. The partner's
grid is looked up from the current decoded message list.

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

Replaces the static QSO map image with a Surface-rendered, full-bleed map on
the Android Auto NavigationTemplate (CarMapSurfaceRenderer), drawn directly each
1 Hz tick so countdown updates never trigger a template refresh (no dimming).

Map:
- Zoom-to-fit framing of the operator and QSO partner (CarMapProjection):
  world view when idle, zoomed in around the two stations when in a QSO, so the
  connection is actually legible. Clamped [1, 12x] with padding.
- World land + national borders (new res/raw/world_countries.json) + US state
  borders, layered under markers; US state abbreviations labelled once zoomed in.

Status:
- Two full-width status banners (CarPanelLayout) — live QSO state (headline +
  slot) on top, band + POTA on the bottom — flush against the car's system bars
  (heights read from framework dimens; safe-area fallback), leaving the map
  centre clear.
- The QSO partner's location rides next to the slot timer: US state (via
  point-in-polygon over us_states.json, UsStateLocator) or DXCC country (from the
  callsign database), e.g. "RX slot 7 s - Massachusetts" / "- Reunion Island".

Testing:
- DEBUG-ONLY DebugInject broadcast receiver (src/debug) + app-automotive host so
  the car map can be exercised on an Automotive OS emulator without a phone/DHU.
- Unit tests for the projection, panel/banner geometry, GeoJSON label + region
  parsing, and point-in-polygon lookup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes eight review findings on the Android Auto surface map path:

- Guard checkIsCQ() against a null callsignTo: it dereferences
  callsignTo unconditionally, so a decode with a null To field would
  crash the 1 Hz surface tick. buildStationMarkers now gates the call.
- Dedupe station markers by the normalized getCallsignFrom() (strips the
  < > a hashed decode carries) instead of the raw callsignFrom field, so
  a hashed decode no longer produces a duplicate marker.
- Match the current QSO partner on getCallsignFrom() too, so a hashed
  partner decode still renders its line/location.
- Cache the empty fallback when a geo asset fails to load, so a
  missing/corrupt asset does not re-throw the parse on every frame.
- Localize the POTA line: buildCarPotaLine now returns a CarStringSpec
  resolved against the (previously unused) @string/car_pota_line.
- Correct stale KDoc/comments that still referenced MapWithContentTemplate
  and host-rendered status cards; the screen uses NavigationTemplate and
  draws the status banners on the surface itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B1egpdNWafvAksJCn1tMA
Android Auto: live surface QSO map (zoom, borders, status banners, partner location)
Compose UI 1.8 (BOM 2025.04.01) deprecates the imperative AutofillNode /
AutofillType / LocalAutofill(Tree) API in favor of the semantics-based
ContentType APIs. Rewrite Modifier.autofill to tag the field's semantics
with its ContentType and receive filled values via onAutofillText, dropping
the hand-rolled node-registration / focus / bounds bookkeeping.

The role -> ContentType mapping is extracted to credentialContentType (was
credentialAutofillTypes) and its unit test is updated accordingly.

Fixes #453.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B1egpdNWafvAksJCn1tMA
getDefaultAdapter() is deprecated on API 18+; the adapter should be obtained
from the BluetoothManager system service instead. Add a small testable helper
bluetoothAdapter(context) that does getSystemService(BLUETOOTH_SERVICE).adapter
and preserves the old null contract, and route both call sites (autoConnect in
ComposeMainActivity, the Bluetooth picker in ConnectionDialogs) through it.

Fixes #454.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B1egpdNWafvAksJCn1tMA
android.os.AsyncTask is deprecated. Convert the GetCallsignMapGrid AsyncTask
into a plain, directly-testable loadCallsignMapGrid(db) method plus an
Executor-backed loadCallsignMapGridAsync(db), and route the Kotlin
(ComposeMainActivity) and legacy Java (MainActivity) call sites through the
async variant so no caller references the deprecated .execute() path.

The remaining AsyncTask subclasses in DatabaseOpr are a separate, larger
sweep and are intentionally left untouched here.

Fixes #455.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B1egpdNWafvAksJCn1tMA
Copilot review: the helper's KDoc implied getDefaultAdapter() was deprecated
on API 18+. API 18 is when BluetoothManager.getAdapter() became available; the
getDefaultAdapter() deprecation landed in Android 12 (API 31). Correct the doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B1egpdNWafvAksJCn1tMA
- DatabaseOpr.loadCallsignMapGrid: cache the callsign/grid column indices once
  before the cursor loop instead of resolving them on every row.
- LoadCallsignMapGridTest: reset the process-global GeneralVariables map in
  @Before/@after for isolation, and move DB creation/close into setUp/tearDown
  so the handle is always closed even when an assertion fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B1egpdNWafvAksJCn1tMA
Migrate off deprecated Compose Autofill API in AutofillModifier.kt
Replace deprecated BluetoothAdapter.getDefaultAdapter()
Replace deprecated AsyncTask in ComposeMainActivity.kt
FT8 is a standalone ADIF mode, but FT4 and FT2 are submodes of MFSK.
Both ADIF writers copied the stored mode name straight into the MODE
field, so an FT2 QSO exported as a bare <MODE:3>FT2 — which pota.app
rejects as an invalid mode (its guidance is MODE=MFSK, SUBMODE=FT2).

Add AdifFormat.mfskSubmode() as the single source of truth for which
modes ADIF treats as MFSK submodes, and route both export paths through
it:
- PotaAdifExporter (POTA upload + share-sheet): new adifMode() emits
  MODE=MFSK + SUBMODE=FT2/FT4 for those modes, otherwise MODE verbatim.
- ShareLogs (general logbook ADIF export): same mapping, fixing the
  identical latent bug for QRZ/LoTW/etc. exports.

FT8 output is unchanged. Covers the new paths with unit tests for the
classifier, adifMode, and an end-to-end FT2 QSO through buildActivationAdif.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B1egpdNWafvAksJCn1tMA
The MFSK-submode fix only covered PotaAdifExporter and ShareLogs, but two
other live ADIF writers still emitted a bare <mode>FT2/FT4:

- ThirdPartyService.QSLRecordToADIF — the payload for QRZ and Cloudlog
  uploads, so FT2/FT4 QSOs uploaded there hit the same invalid-mode
  rejection the PR set out to fix.
- DatabaseOpr.downQSLTable — the logbook ADIF text export.

Route both through AdifFormat.mfskSubmode() so FT2/FT4 export as
MODE=MFSK + SUBMODE, matching the other export paths; FT8 stays a bare
standalone MODE. Make QSLRecordToADIF package-private so its payload can
be unit-tested directly, and add Robolectric tests pinning the ADIF
output of both paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Export FT4/FT2 QSOs as MFSK submodes in ADIF
- QsoStatusScreen.onStop now clears the AppManager SurfaceCallback it
  registered in onStart, so the host stops delivering surface events to a
  stopped screen and doesn't keep the callback/renderer referenced while
  another screen is on top. onStart re-registers on return.
- PSK "who heard me" fetch loop forces the first fetch after (re)start so
  the client-wide cooldown (from a recent fetch on another screen) can't
  skip the initial car fetch and leave the overlay's PSK rings empty; and
  clears WhoHeardMeCache when the operator callsign is blank so stale rings
  don't linger.
- Fix AndroidManifest comment: the surface map uses NavigationTemplate, not
  MapWithContentTemplate (matches QsoStatusScreen's KDoc and buildMapTemplate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B1egpdNWafvAksJCn1tMA
Release: promote dev → staging
@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 25.58140% with 928 lines in your changes missing coverage. Please review.
✅ Project coverage is 21.66%. Comparing base (ee76486) to head (711a56a).

Files with missing lines Patch % Lines
ft8af/app/src/main/cpp/ft8_lib/ft8/message.c 0.00% 287 Missing ⚠️
ft8af/app/src/main/cpp/ft8_lib/ft8/unpack.c 0.00% 258 Missing ⚠️
ft8af/app/src/main/cpp/ft8_lib/ft8/osd.c 0.00% 154 Missing ⚠️
desktop/src-tauri/src/engine.rs 26.06% 122 Missing ⚠️
desktop/src-tauri/src/audio/output.rs 33.89% 39 Missing ⚠️
...debug/kotlin/radio/ks3ckc/ft8af/car/DebugInject.kt 40.74% 29 Missing and 3 partials ⚠️
ft8af/app/src/main/cpp/ft8_lib/ft8/decode.c 0.00% 16 Missing ⚠️
desktop/src-tauri/src/main.rs 0.00% 13 Missing ⚠️
desktop/src-tauri/src/wf.rs 97.11% 6 Missing ⚠️
desktop/src-tauri/src/dsp/decoder.rs 75.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff              @@
##               main     #465      +/-   ##
============================================
+ Coverage     11.70%   21.66%   +9.95%     
- Complexity      113      149      +36     
============================================
  Files            85      163      +78     
  Lines         11691    20877    +9186     
  Branches       2110     3128    +1018     
============================================
+ Hits           1369     4523    +3154     
- Misses        10191    16167    +5976     
- Partials        131      187      +56     
Flag Coverage Δ
android 13.13% <40.74%> (?)
desktop 39.86% <62.13%> (?)
ios 96.38% <ø> (?)
native 9.93% <0.00%> (?)

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

Files with missing lines Coverage Δ
desktop/src-tauri/src/audio/slot.rs 71.83% <100.00%> (ø)
...n/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt 2.35% <ø> (-0.23%) ⬇️
...app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt 0.50% <ø> (-0.36%) ⬇️
...in/kotlin/radio/ks3ckc/ft8af/PerBandOutputLevel.kt 76.92% <ø> (ø)
...pp/src/main/kotlin/radio/ks3ckc/ft8af/TuneLevel.kt 18.18% <ø> (ø)
.../kotlin/radio/ks3ckc/ft8af/car/CarMapProjection.kt 97.56% <ø> (ø)
...in/radio/ks3ckc/ft8af/car/CarMapSurfaceRenderer.kt 0.00% <ø> (ø)
...in/kotlin/radio/ks3ckc/ft8af/car/CarPanelLayout.kt 100.00% <ø> (ø)
...main/kotlin/radio/ks3ckc/ft8af/car/CarQsoStatus.kt 94.20% <ø> (ø)
...n/kotlin/radio/ks3ckc/ft8af/car/CarSurfaceState.kt 26.66% <ø> (ø)
... and 50 more

... and 59 files with indirect coverage changes

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

@patrickrb
patrickrb requested a review from Copilot July 8, 2026 02:42

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.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

main and staging each performed the ft8cn->ft8af package/module rename
independently, producing add/add conflicts across ~50 files, plus a few
content conflicts where staging carries newer code (e.g. Kotlin 2.0.21 vs
main's 1.9.22). staging is the forward-most branch and a content superset
of main (no file outside the old ft8cn/ path exists in main but not in
staging), so every conflict is resolved in favor of staging. The resulting
tree is byte-identical to origin/staging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B1egpdNWafvAksJCn1tMA
@patrickrb

Copy link
Copy Markdown
Owner Author

Resolved the merge conflicts in 711a56a by merging `origin/main` into `staging`.

Root cause: `main` and `staging` each performed the `ft8cn`→`ft8af` package/module rename independently, producing add/add conflicts across ~50 files, plus a handful of content conflicts where `staging` carries the newer code (e.g. Kotlin 2.0.21 vs main's 1.9.22).

Verified that no file outside the old `ft8cn/` path exists in `main` but not in `staging` — i.e. `staging` is a content superset of `main` — so every conflict was resolved in favor of `staging`. The resulting merge tree is byte-identical to `origin/staging` (git diff origin/staging HEAD is empty). PR is now mergeable.

No review threads to address (Copilot skipped inline review as the PR exceeds its 300-file limit; the Codecov comment is informational).

@patrickrb
patrickrb merged commit c2f63e8 into main Jul 8, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants