Conversation
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("<", "<")` 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>
#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 Report❌ Patch coverage is Additional details and impacted files@@ 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
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Promote dev → staging (localized Play Store listings)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Production promotion.
mainwas last updated 2026-07-07; this carries everything merged since.Scope
That diff is dominated by the iOS port and vendored data; the Android app changes are a much smaller slice.
A push to
mainrunsandroid.ymlin its production lane: auto-bumpedandroid-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 theplay-publishconcurrency 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;stagingis 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