Skip to content

Promote staging → main (production release) - #793

Closed
patrickrb wants to merge 555 commits into
mainfrom
staging
Closed

Promote staging → main (production release)#793
patrickrb wants to merge 555 commits into
mainfrom
staging

Conversation

@patrickrb

Copy link
Copy Markdown
Owner

Production promotion. main was last updated 2026-07-07; this carries everything merged since.

Scope

Commits 546 (173 merged PRs)
Range 2026-07-08 → 2026-08-26
Diff 7,584 files, +143,448 / −5,701

That diff is dominated by the iOS port and vendored data; the Android app changes are a much smaller slice.

⚠️ This publishes to Play production

A push to main runs android.yml in its production lane: auto-bumped android-v* tag, GitHub release, and an AAB to the Play production track. This is not a staging rehearsal — merging ships to users.

It also runs play-listings.yml, which publishes the localized store listings. Both share the play-publish concurrency group, so they serialize rather than colliding on Play's one-open-edit-per-app rule.

Merge #792 first

This PR does not currently contain the store listings. They are on dev; staging is 8 commits behind. #792 (dev → staging) carries them.

Merge #792 first and this PR picks them up automatically, since it tracks staging. Merging this one alone ships the app to production and leaves all 17 store pages in English — then getting the listings out needs a second production release.

What's in it

Highlights from the 173 PRs — CAT and audio reliability, POTA, and the iOS port:

Before merging

🤖 Generated with Claude Code

Optio Agent and others added 30 commits July 22, 2026 14:36
Address Copilot review: the trailing calendar icon is a touch-only hit
target on the EditText's compound drawable, so TalkBack users could not
activate the picker. Register a ViewCompat custom accessibility action
("Open calendar date picker") on each field so the picker path is
operable via accessibility services, not just touch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The USB-direct (libusb) TX path in UsbAudioDevice.writeAudio() upsampled the
12 kHz FT8 waveform to the device's 48 kHz rate with naive linear interpolation.
A linear interpolator convolves with a triangular kernel (sinc^2 response),
which only lightly attenuates the spectral images of the 12 kHz-sampled tone.
For a ~1500 Hz FT8 tone those images land at 10.5/13.5 kHz — inside the 48 kHz
output band — and ride into the radio's modulator as audible harmonic
distortion on TX (Yaesu FT-710 report). The phone-speaker path stays clean
because the OS USB driver resamples with a proper band-limited filter; only the
app's own direct-libusb path used the crude interpolator.

Replace it with TxUpsampler, which reuses the host-tested Blackman-windowed-sinc
polyphase kernel already used on the capture side (RationalResampler): exact L/M
rational resampling with a stopband well below FT8's ~3 kHz top, group-delay
compensated so the leading Costas sync array is not shifted or clipped. Images
are now rejected by >40 dB.

Adds TxUpsamplerTest covering length, frequency/amplitude preservation, image
rejection vs. the old linear path, the 44.1 kHz non-integer ratio, and
degenerate-rate guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Log.e(TAG, msg, e) instead of string-concatenating the exception, so the
  full stack survives into logcat for field debugging.
- Correct the Javadoc: the helper is not "free of Android types" — it calls
  android.util.Log, which is a returnDefaultValues stub in unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A write() interrupted by a signal leaves the fd healthy, but the loop broke
on any write() <= 0 — so a signal landing on the CAT read thread mid-write
silently dropped the rest of the rig's reply and hamlib saw a short frame.
Retry on EINTR; every other non-positive return still ends the loop.

New host test case 6 fills the pipe to capacity (the only state where the
kernel reports -1/EINTR rather than a short write) and interrupts the blocked
write with a repeating SIGALRM installed without SA_RESTART. It fails on the
pre-fix loop and passes on the fixed one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
It described a 6-character grid from NMEA-format coordinates; the method
takes decimal-degree LatLng values and returns a 4-character locator.

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

DiscardPolicy drops every rejection, so a rejection during normal operation
(e.g. thread creation failing under resource exhaustion) would silently
swallow the cycle/heartbeat callback. DiscardOnShutdownPolicy discards only
when executor.isShutdown() — the teardown race this fix targets — and
delegates everything else to AbortPolicy, keeping real failures visible.

New test: a saturated still-running pool with the policy installed still
throws RejectedExecutionException.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LogHttpServer (NanoHTTPD on port 7050, bound to all interfaces with no
auth) echoed untrusted query params and user-controlled DB fields into
generated HTML with no or incomplete escaping. The ad-hoc
`.replace("<", "&lt;")` calls escaped only `<`/`>`, so a `"` still broke
out of a `value="…"` attribute — e.g. `?callsign="><script>…` in
showQslCallsigns/getCallsignQTH executed script.

Add a single central HtmlContext.htmlEscape() (escapes & < > " ', &
first, null-safe) and apply it to every request-derived value and
user-controlled DB field before it enters markup:
- showQslCallsigns / getCallsignQTH query-param reflections (attribute)
- getCallsignQTH, getQSLCallsigns callsign/grid/mode/band cells
- follow-callsign list (element + delfollow href, now quoted)
- message / QSOSWLMSG / QSOLogs lists: callTo/callFrom/call/
  station_callsign/extra/gridsquare/operator/comment

Removes the partial `.replace("<", …)` escapes in favor of the central
helper. SQL is already parameterized, so this is HTML/XSS only.

Adds HtmlEscapeTest covering element- and attribute-context (double- and
single-quote) breakout payloads, &-first ordering, and null/passthrough.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The join timeouts were ignored and `stop` was only set after joining, so a
stalled worker could let the test pass while leaking a live thread. Workers
are now daemons, the joins share one 30s deadline, any thread still alive at
that point is asserted as a failure, and `stop` is used only as the fallback
that asks the loops to bail out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The worked-station handling (Settings → Decode Highlights) lets the operator
pick which stations count as worked — on this band, worked before anywhere,
worked today, or from a list — and what to do with them (highlight / ignore /
hide). The feature request additionally asked for the "…on this band and mode"
variants, i.e. only treating a station as worked when the earlier QSO was on
the same mode you're operating.

This adds that as an orthogonal "Same mode only" toggle. When on,
WorkedModeFilter appends an `upper(mode) = ?` predicate to the worked lists
loaded in DatabaseOpr.GetAllQSLCallsign (current-band, other-band and today),
so a station worked only on a different mode (e.g. FT4 while you're on FT8)
still shows as new. The filtering happens at list-load time, so every scope
that reads those lists honours it automatically; FROM_LIST is user-maintained
and unaffected. WorkedModeFilter is a plain, side-effect-free helper so the
predicate is unit-testable without Robolectric.

The setting is persisted via writeConfig("workedSameMode", …), hydrated in the
config loader, and the worked lists reload immediately when it changes. The
toggle is only offered for the band/before/today scopes.

Tests: WorkedModeFilterTest (pure predicate/arg logic) and
GetAllQSLCallsignModeTest (Robolectric, drives real SQLite to confirm the
refinement filters all three worked lists).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Streams.readAllBytes can throw part-way through a read, and the manual
close() after it was skipped on that path, leaking the AssetInputStream.
Same fix in both help/clear-cache dialogs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Javadoc: -1 also covers "sync run present but the length byte hasn't
  arrived yet" (a read ending inside the run), not just "no sync".
- Test comment: the scattered 0xA5s are sync bytes; the old bug was
  returning an index into the sync run so the caller read a sync byte as
  the length byte.

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

Fix decode-screen NPE crash when a message has a null destination call
…a6ef-3628b8feee87

Guarantee TX teardown when the AudioTrack sound-card path throws
…lash callsigns

getStdCall("/") (or any all-slash string) crashes: String.split("/") strips
trailing empty tokens, so an all-slash input produces a zero-length array. The
method skips both loops and then evaluates callsigns[0], throwing
ArrayIndexOutOfBoundsException.

This is the same defect PR #509 fixed in GeneralVariables.getShortCallsign;
that guard was never mirrored here. getStdCall runs during TX packing
(generatePack77_i1) when both parties have compound callsigns, so a
misconfigured all-slash own-callsign turns every transmit into an exception.

Fix: fall back to the input when the split yields no segments, mirroring the
getShortCallsign guard. Adds unit coverage for all-slash, trailing-slash, and
leading-slash callsigns to FT8PackageTest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getMeter_volt's low segment (raw value 0..75) divided the raw meter
value by 25 instead of 15. The class-documented calibration is
0000=0V, 0075=5V, 0241=16V, so the 0..75 segment must be linear from 0V
to 5V (slope 5/75 = 1/15). Dividing by 25 mapped that segment to 0..3V,
under-reporting the supply voltage across the whole low range and
leaving a discontinuity with the correct upper segment at the knee
(value 75 read 3.0V on the low branch vs 5.0V on the upper branch).

Only the low branch changes; the upper segment already matched the
calibration and is untouched. This only affected the displayed supply
voltage on the Xiegu X6100 network meter panel (a battery-powered
radio, where an accurate low-voltage reading matters), not TX behavior
or protocol.

Adds pure-JVM X6100MetersVoltTest covering both calibration knees, the
segment continuity at value 75, and monotonicity; the low-segment cases
fail against the old /25 divisor.

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

Don't upload the RR73 sign-off as a locator to PSKReporter
…9112-529de8e453d3

Fix Yaesu FT-817/857/897 set-frequency encoder tens-of-Hz nibble
…b26c-04c1ff30f0e7

iOS: don't upload the RR73 sign-off as a locator to PSKReporter
…9faa-c58b7721d1e9

Null-check the hamlib JNI bridge's array/string pins
…b423-2c8736434f2e

Clamp Maidenhead grid indices so a pole/antimeridian GPS fix can't emit an invalid locator
…b140-f8261313bdd9

Stop a crash on app exit / mode switch: discard UtcTimer pool submits during teardown
…8550-a1902373f7ca

Fix map range rings so they align with station markers (drop stray PI/2 factor)
…b156-1002d1086ca0

Read bundled data assets fully so a short read can't corrupt the callsign/DXCC/zone databases
…8eec-1edd9ec46f60

Desktop: stop auto-keying TX after the decoder is stopped
…b999-f329af2958f1

Guard the web-logbook upload handler against a missing file1 part
…9049-3dae2e249565

Fix GuoHe Q900 frame sync: require four consecutive 0xA5 bytes
…a6eb-cfa87986b938

Fix: TX Delay not applied until value is changed
…bfd3-c8e2b22eb6a9

Fix SWR protection on Lab599 Discovery TX-500 (#599)
…8b0e-237c3639af6e

Export QSOs: date-range filter uses a Material date picker (typing still works)
- The stopband claim was directionally wrong. The cutoff is 0.45/max(L,M) of
  the interpolated rate (~5.4 kHz for a 12 kHz source): the passband clears
  FT8's ~3 kHz top, and it's the 10.5/13.5 kHz images that sit in the
  stopband.
- Group-delay compensation is a whole number of output samples, so for a
  non-integer ratio alignment holds to within one output sample, not exactly.
- Stop implying 48 kHz is the only device rate in UsbAudioDevice.resample and
  the test header; 44.1 kHz (147/40) takes the same path and is covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
patrickrb and others added 27 commits August 25, 2026 13:56
#754)

Connecting to an IC-705 over WLAN gave no sign the link was up — the user had
to guess by watching the waterfall — and a dropped Wi-Fi link just froze the
waterfall with no message. The Icom network connector never fired any of the
connector state callbacks the USB and Bluetooth connectors use, so the CAT
status chip stayed grey the whole session and the CAT liveness watchdog (which
detects a silently-dead link) never started.

Fix:
- New WifiLinkState: edge-guards the Icom UDP stack's repeated login/status
  packets and its send-error/close events into single connect/disconnect edges
  (the rig re-sends 0x60/0x50; a fault-then-close must not double-report).
- WifiRig gains an OnLinkStateChanged hook; IComWifiRig and XieGuWifiRig fire it
  on login response, UDP send error, and close.
- WifiConnector forwards those edges to the shared OnConnectorStateChanged
  pipeline (onConnecting/onConnected/onDisconnected/onRunError), so the chip
  goes amber→green on connect and red/grey on drop, connect/disconnect toasts
  fire, and the CAT liveness watchdog runs on the network path — giving silent
  Wi-Fi drops a detectable "went quiet" signal.
- WifiConnector.isConnected() now reflects the real post-login state instead of
  wifiRig.opened (true the instant start() runs — the Settings header used to
  claim "connected" with the radio off).
- connectWifiRig() wires the rig-state listener BEFORE connect() so the new
  edges aren't lost.

Removed a stray, incomplete IcomLinkTracker.java left in the tree.

Tests: WifiLinkStateTest (edge cases) and WifiConnectorLinkStateTest (end-to-end
connect/login/drop/disconnect wiring, and the isConnected() lie).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…audio device (#723)

FT8AF only started Android's Bluetooth SCO (headset) link when the *rig*
connection mode was Bluetooth. A user on a USB or VOX rig who selected a
Bluetooth headset as the FT8 mic/speaker got no SCO, so the app captured the
built-in mic instead and the headset never worked — the known workaround was to
launch FT8CN first purely to turn SCO on.

SCO must stay gated (opening it knocks a paired car/headphones out of A2DP
music), but picking a BT headset as the FT8 audio device is explicit consent to
route over it — the signal the old gate was missing.

Fix:
- ScoPolicy: audioSelectionNeedsHeadsetMode() + a 4-arg shouldEnterHeadsetMode()
  that enters SCO when a BT rig is in use OR a BT-SCO input/output device is
  selected, but only while a BT audio profile is actually connected.
- AudioDeviceSpinnerAdapter.getDeviceType(position) exposes the AudioDeviceInfo
  type so the picker can detect a BT-SCO choice.
- MainViewModel.refreshBluetoothHeadsetMode(): brings SCO up/down to match the
  rig + selected devices and rebuilds the AudioRecord so capture binds to the
  SCO route; guarded so it only toggles on a real change and never yanks SCO
  from a Bluetooth rig.
- Called at launch (replacing the rig-only gate) and from both audio-device
  pickers.

Note: PR #772 (issue #759) reworks how SCO is brought up/retried; this changes
*when*. They're complementary — whoever merges second resolves the small
MainViewModel overlap.

Tests: ScoPolicyAudioDeviceTest (device-type detection + all gate combinations,
incl. the framework-constant check and the car-stereo protection).

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

Force-claim the device's UAC AudioControl interface (not just the streaming
interfaces) whenever the app opens a USB audio device for direct libusb use,
for RX and for each TX cycle.

Why: claiming only the AudioStreaming interfaces is a silent no-op for the
kernel's snd-usb-audio driver, which binds the card at the AudioControl
interface and treats the streaming interfaces as owned-but-unused. The ALSA
card therefore survived, Android kept the rig's CM108 registered as a
usb_headset sink+source, and every sound Android routed there -- the app's
own QSO-complete DX-alert ding, a BT car-kit connect re-route, a nav prompt
-- made the kernel driver flip the playback interface's alt-setting under
our in-flight iso URBs. The kernel completes those with -ESHUTDOWN, libusb
reports rc=5 TRANSFER_NO_DEVICE ~280 ms into the TX with the device still on
the bus, and the cycle goes out as dead air. In the 2026-08-25 bench log 20
of 22 such failures were preceded by a QSO-complete alert 1.9 s earlier; the
other two coincided with a Bluetooth car kit connecting.

Claiming the AudioControl interface runs the real usb_audio_disconnect, so
the ALSA card is retired and nothing Android plays can reach the endpoint.
The per-cycle TX open skips the claim when the session-long RX capture on
the same device already holds it. A missing AudioControl interface or a
refused claim is logged and ignored, so behaviour on odd devices is unchanged.

Side effect, by design: while the app holds the device, phone audio that
Android would have routed into the rig's mic input is dropped instead (it
was inaudible to the operator either way, and could have been keyed on
air). The kernel does not rebind the driver on release; the card returns on
the next unplug/replug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- refreshBluetoothHeadsetMode(): cross-check the cached btHeadsetModeActive
  flag against AudioManager.isBluetoothScoOn() via the new pure
  ScoPolicy.headsetModeAction(). A headset that dropped SCO on its own (or a
  failed setBlueToothOn()) no longer leaves the flag stuck true and the
  selected BT mic/speaker dead until restart; SCO that is really up is still
  not stop/started again on every settings tap, and a Bluetooth rig's SCO is
  never yanked from here.
- AudioDeviceSpinnerAdapter.getDeviceType() Javadoc: only the USB-direct rows
  return -1; Android-routed USB AudioDeviceInfo rows return their real type.
- ScoPolicyAudioDeviceTest: read AudioDeviceInfo.TYPE_BLUETOOTH_SCO reflectively
  so javac can't inline it into a 7 == 7 tautology; add headsetModeAction tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two valid findings on the #753 repair:

1. reconcileWithModel() could not tell the old decimal write from a
   deliberate hex override that happens to be a decimal twin of the model
   address (0x88 on an IC-706 = "88"), so connectRig() would reset such an
   override every launch and persist the model default. Add a provenance
   marker: every hex-aware writer (Compose picker, legacy ConfigFragment,
   the repair itself) also stores civFormat=hex. A marked value is trusted
   verbatim and never reconciled; only unmarked values get the one-time
   model check.

2. The three-digit decimal case ("164" -> 0xA4) was never written back,
   because the decoded address already equalled the model's, so "164"
   stayed in the database on every launch. Hydration now keeps the raw
   stored text; CivAddressConfig.planRepair() decides both the address and
   whether a write-back is due (marker missing, or stored text not the
   canonical encode() form), and connectRig() writes civ + civFormat then.

planRepair() is pure and covered by CivAddressConfigTest; the new
hydration fields are covered by DatabaseOprConfigHydrationTest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- WifiConnector.connect() now resets the WifiLinkState before wifiRig.start().
  reconnectRig() reuses the connector, so the terminal flag left by the previous
  close/error swallowed the next successful login: the chip stuck on
  "connecting" and isConnected() stayed false for the re-established session.
- WifiRig gains a monotonic link session id: start() tags each attempt and the
  concrete rigs' stream-event handlers report login/send-error through
  session-checked notify* overloads, so a late packet from the previous
  ControlUdp's sockets can't be applied to the new attempt.
- WifiLinkState transitions and isConnected() are synchronized: login results
  arrive on the receive worker, send errors on the failing stream's sender, and
  close on the UI thread.

Tests: WifiLinkStateTest (reset x3, concurrent edge count), WifiConnectorLinkStateTest
(reconnect after drop / login failure / user disconnect), new WifiRigLinkSessionTest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- network_security_config: a domain-config can't express "cleartext only
  for the host the user typed", so the base-config stays permissive, but the
  exposure is now contained: support.qq.com joins the pinned-TLS list, and
  the embedded WebViews (QRZ lookup, FAQ) refuse non-https navigations via
  a new, tested WebNavigationPolicy so link-following inside them stays
  HTTPS-only as before.
- CloudlogEndpoint.candidates: detect the no-rewrite form by a terminal
  /index.php/ *path* segment instead of contains("index.php"), so a host
  like index.php.example still gets the fallback.
- ThirdPartyService.redactUrlApiKey also masks authority user-info
  (http://user:pw@host/...) so debug.log never carries a basic-auth
  password.
- Extract the GET/POST candidate walk into cloudlogRequest() behind a
  package-private CloudlogTransport so the 404-only fallback, stop-on-other
  status/exception and remembered-variant reordering are unit-tested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Route a connect timeout through the same deferred, re-checked retry as
  DISCONNECTED/ERROR (spaced by RETRY_DELAYS_MS, cancelled by a late
  CONNECTING/CONNECTED) instead of an immediate RESTART.
- Extract the handler-facing orchestration from MainViewModel into
  ScoLinkCoordinator: every request, broadcast, retry and timeout is applied
  on one looper in submission order, so TX-executor stopSco()/startSco()
  can no longer interleave with main-thread broadcasts; AudioManager calls,
  the mic re-route check and logging go through an injectable Sink and the
  clock is injected.
- onCleared() now shuts the coordinator down: pending retry/timeout/mic-check
  callbacks are dropped and an outstanding start is balanced with a stop.
- Robolectric ScoLinkCoordinatorTest covers timeout/retry scheduling and
  cancellation, the CONNECTED mic check, cross-thread ordering and teardown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- open(): validate the endpoint scan first; on a device with no usable iso
  endpoint close the connection and return false instead of leaking it (and
  no longer detach Android audio from a device we can't use).
- Extract the claim decision + effect behind a KernelDetachPort so the
  stateful branches (claim on the AC interface, skip when the RX session
  already holds it, refused claim) are covered by UsbAudioKernelDetachTest.
- Correct the describeLibusbWriteError / shouldFallbackToUsbRequest docs:
  rc=5 is the kernel tearing down the endpoint, rc=-4 is a real bus removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bluetooth SCO: track link state, retry, and rebuild the mic on connect (Android 8.x RX dead, #759)
Fix Cloudlog/Wavelog Test Connection over cleartext LAN + report the reason (#756)
Fix ICOM CAT frequency control: civ address stored decimal, read hex (#753)
Surface IC-705 network connect/disconnect on the CAT status chip (#754)
Enter Bluetooth headset (SCO) mode when a BT headset is the selected audio device (#723)
…iver

Detach kernel USB-audio driver so Android sounds can't kill a USB-direct TX
Bluetooth SCO:
- refreshBluetoothHeadsetMode() cross-checks the coordinator's tracked link
  state (isLinkUpOrPending / isWanted) instead of AudioManager.isBluetoothScoOn(),
  which only mirrors the legacy force-use flag and can read false with the link
  up — deselecting a BT headset on a USB/VOX rig then FORGOT instead of LEAVING
  and SCO stayed on. ScoPolicy.headsetModeAction takes linkUp + linkHeld.
- Profile connection changes on non-Bluetooth rigs now run the selection-aware
  refresh (ScoPolicy.profileChangeAction) so a selected BT headset that
  reconnects after the retry budget ran out re-enters headset mode (#723).

Icom/Xiegu WLAN link:
- Stream-event handlers capture their session's ControlUdp and go through
  WifiRig.admitSessionEvent: a stale send error / login closes only its own
  sockets and never close()s the reconnected session. start() closes any
  previous ControlUdp first. Data callbacks are session-gated.
- WifiLinkState carries the session id; the connector resets it on the new
  OnLinkStateChanged.onSessionBegin edge fired inside beginLinkSession(), so
  reset and event admission are atomic instead of racing start().

CI-V address:
- Unmarked two-digit decimal twins of the model address ("88" on an IC-706)
  are no longer silently rewritten — they are indistinguishable from a
  deliberate hex override and no UI remains to restore one. planRepair keeps
  the value, marks it, and flags it ambiguous; the app logs and toasts a
  one-time hint to re-select the rig if it uses the default.

Tests updated/added for every new path (91 in the touched classes; full
suite 3486 green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address Copilot review on dev → staging promotion (PR #778)
Promote dev → staging (IC-705 CAT, Cloudlog LAN, BT audio, USB TX fixes)
The app UI is translated into 17 languages, but the Play Store listing only
existed in English — a user browsing the store in Japanese or Portuguese saw
an English page for an app that would have greeted them in their own language.

Put the listing text under version control instead of the Play Console:

- fastlane/metadata/android/<play-locale>/ holds title.txt,
  short_description.txt, and full_description.txt for 18 locales — the 17 the
  app ships plus es-419, which is the Spanish copy in Latin American
  vocabulary rather than Iberian. The English master is the current live
  listing, with the "full English localization" line updated to the 17
  languages that are now real, and a double space fixed.
- .github/scripts/publish_listings.py pushes them via the Play Developer API,
  reusing the PLAY_SERVICE_ACCOUNT_JSON service account that play_track_watch.py
  and the release upload already use. It PATCHes (not PUTs) so an existing
  promo video survives, sends only locales whose text actually differs, and
  abandons its edit when there is nothing to commit. --dry-run shows the diff;
  --pull resyncs the tree after someone edits in the Console.
- .github/workflows/play-listings.yml validates on PRs (no secrets, so forks
  work) and publishes on a push to main touching the metadata. It shares the
  play-publish concurrency group with android.yml, because Play allows only
  one open edit per app and a listing update racing an AAB upload fails.
- docs/store-listings.md covers the locale-code mapping (Play codes are not
  the Android resource qualifiers), the character limits, and what is
  deliberately out of scope.

Character limits are enforced by the unit tests rather than by an API 400:
the suite loads the checked-in tree and names any file that is over. That
matters most for the 80-character short description, which English already
fills exactly, so no translation could be literal.

Graphics are untouched — Play falls back to the default language's
screenshots for every locale, so all 17 listings show the English set until
per-language captures land. Release notes stay with android.yml.
PLAY_SERVICE_ACCOUNT_JSON was granted release permission, not "Edit store
listing, pricing & distribution". Name the grant and point at the manual dry
run, which hits the same API path and surfaces a missing permission before a
release does.
- abandon_edit(): check the DELETE response and warn on stderr when Play
  refuses it. Deliberately does not raise_for_status() — it runs in a finally
  block, so raising would replace an in-flight commit failure with a cleanup
  error. A regression test asserts a 500 commit still surfaces as a 500 even
  when the cleanup also fails.

- run_pull(): report locales that exist locally but not on Play instead of
  passing over them silently. They are NOT deleted: before the first publish
  that describes all 17 non-English locales, so removing them would destroy
  the work --pull exists to protect. Retiring a language stays a manual delete.

- Replace the weak "at least 2 locales" tree assertion with the exact expected
  locale set, and add a gate that walks the app's own res/values-* directories
  and fails if a shipping language has no listing — the real failure being
  guarded against, since a translated UI with an English store page is silent.

- Cover the Play API lifecycle with a fake session: unchanged, dry-run,
  changed-only patch selection, new locale, remote-only locale, pull round
  trip, null fields, commit, and edit cleanup on every exit path.

- cs-CZ: "Volba vysílacího period" -> "vysílací periody" (perioda is feminine).

- en-US: capitalize Hamvention, matching the other 17 locales and the README.

51 tests, up from 31.
…(PR #784)

The helper documented that it never raises, but only handled an error status:
a timeout or dropped connection from the DELETE itself still escaped, and it
runs in a finally block, so it would replace the commit/PATCH failure that
actually mattered. Wrap the call and report the exception type and message the
same way an error status is reported.

except Exception is deliberate and now pinned by a test — KeyboardInterrupt
derives from BaseException and still stops the run rather than being swallowed
as a failed delete.
The live English listing predated most of what FT8AF does now. It described
an FT8-only app with a logbook and a map, and never mentioned FT4 or FT2,
Parks on the Air, Hunt, Hound, clock sync, Worked All States, voice control,
or Android Auto — all of which are in the shipping build. Rewrote the master
against the current feature set and re-translated all 17 locales from it.

New sections: HUNT WHAT YOU'RE MISSING (auto-answer, priority ranking, the
NEW DXCC / NEW GRID / NEW ZONE flags), PARKS ON THE AIR (park search,
self-spot, park-to-park, log upload), STAYING ON TIME (NTP, GPS discipline,
self-trim from decode DT), HANDS-FREE (spoken announcements, push-to-talk,
Android Auto). FT4 and FT2 now appear in the mode line, Worked All States in
the logbook section, Bluetooth headsets in audio, DigiRig in what-you-need.

The opening also leans on what reviewers actually report — on the air within
minutes, the app tunes the rig for you, the contact logs itself. Written as
capability rather than quoted testimony, so nothing here depends on a review
staying up or on how Play treats testimonials in listing text.

Short descriptions now lead with FT8 & FT4 and park activation rather than
FT8 alone.

English grew from 2273 to 3303 characters. It was deliberately trimmed to
leave room for translation: the romance and slavic locales run 5-15% longer,
and the longest (fr-FR, 3789) still clears the 4000-character cap. The
existing test asserts that for every locale on every run.
- --dry-run now prints a unified diff per changed field, Play's text against
  the repo's, instead of only character counts. A same-length edit was
  completely invisible: the hamvention -> Hamvention fix rendered as
  "31 chars -> 31 chars". Real pushes still print counts only, so CI logs stay
  readable.

- Add --check-permissions. A dry run only READS listings, so an account with
  release permission but not "Edit store listing, pricing & distribution"
  passed it and would fail on the first real publish — which is exactly the
  gap this PR has been flagging. The probe does the one thing a dry run
  skips: a single listings.patch inside an edit it then abandons rather than
  commits. It writes back the text Play already has, so it is a no-op even in
  principle, and a 403 names the missing grant.

- Extract build_credentials() so an unusable key becomes a CredentialsError.
  service_account_info() only proves the JSON parses and names a service
  account; google-auth is what rejects a missing private_key or a corrupted
  PEM, via ValueError, which main() did not catch. Taking the factory as an
  argument makes the conversion testable without google-auth installed.

- The three modes are now mutually exclusive as a group, not just --pull with
  --dry-run.

- docs: the dry run does NOT verify the listing-edit grant; say so and point
  at --check-permissions instead of claiming it covers the same API calls.

- docs: drop the stale "English short description sits at exactly 80
  characters" — it is 69 since the copy refresh. Restate the real constraint.

- fr-FR: "Choisissez qui vous répondez" -> "Choisissez à qui vous répondez".

- en-US: "70mph" -> "70 mph", matching the other locales.

71 tests, up from 54.
- Run the listings gate when the app's own translations change. The workflow
  triggered only on fastlane/** and the scripts, so a PR adding just
  values-xx/strings_compose.xml never ran test_every_app_language_has_a_listing
  — which is exactly the PR that test exists to catch. Added
  ft8af/app/src/main/res/values*/strings_compose.xml to the PR paths; the glob
  covers all 17 language directories including the default values/.

- --check-permissions no longer diagnoses every failure as a missing grant.
  A timeout, a rate limit, or a Play 5xx reached the same branch as a 403 and
  told the operator to go edit Console permissions that were fine. Now 401/403
  is DENIED (exit 1) with the grant instructions, and anything else is
  INCONCLUSIVE (exit 2) saying plainly that the probe reached no verdict.

- docs: install google-auth and requests before the local examples. There is
  no requirements file, so a clean checkout died at the lazy imports in
  play_session().

The test fake now carries the response on its HTTP error the way
requests.HTTPError does, which is what lets the status be told apart.

75 tests, up from 71.
Localize Play Store listings into all 17 shipping languages
@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 36.01533% with 167 lines in your changes missing coverage. Please review.
✅ Project coverage is 42.34%. Comparing base (9491c32) to head (a8ab16d).

Files with missing lines Patch % Lines
...app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt 0.00% 118 Missing ⚠️
...debug/kotlin/radio/ks3ckc/ft8af/car/DebugInject.kt 61.70% 35 Missing and 1 partial ⚠️
...n/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt 0.00% 11 Missing ⚠️
desktop/src-tauri/src/engine.rs 93.75% 1 Missing ⚠️
...pp/src/main/kotlin/radio/ks3ckc/ft8af/AppInsets.kt 66.66% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff              @@
##               main     #793      +/-   ##
============================================
+ Coverage     36.62%   42.34%   +5.71%     
- Complexity      197      226      +29     
============================================
  Files           216      267      +51     
  Lines         26885    31834    +4949     
  Branches       3294     3650     +356     
============================================
+ Hits           9847    13479    +3632     
- Misses        16811    18097    +1286     
- Partials        227      258      +31     
Flag Coverage Δ
android 16.90% <27.82%> (+1.86%) ⬆️
desktop 63.06% <96.77%> (+0.60%) ⬆️
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%) ⬆️
...p/src/main/kotlin/radio/ks3ckc/ft8af/ScreenWake.kt 100.00% <100.00%> (ø)
...main/kotlin/radio/ks3ckc/ft8af/car/CarQsoStatus.kt 94.44% <ø> (+0.24%) ⬆️
...n/kotlin/radio/ks3ckc/ft8af/car/QsoStatusScreen.kt 0.00% <ø> (ø)
...tlin/radio/ks3ckc/ft8af/hunt/HuntTargetSelector.kt 56.57% <ø> (ø)
...tlin/radio/ks3ckc/ft8af/pota/PotaSessionManager.kt 13.63% <ø> (+0.73%) ⬆️
...adio/ks3ckc/ft8af/pskreporter/PskReporterClient.kt 76.37% <ø> (+0.37%) ⬆️
...adio/ks3ckc/ft8af/pskreporter/PskReporterSender.kt 68.34% <ø> (+1.55%) ⬆️
...src/main/kotlin/radio/ks3ckc/ft8af/qrz/LruCache.kt 100.00% <ø> (ø)
...main/kotlin/radio/ks3ckc/ft8af/qrz/QrzWebClient.kt 0.00% <ø> (ø)
... and 47 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.

Promote dev → staging (localized Play Store listings)
@patrickrb patrickrb closed this Sep 2, 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