Conversation
… race) A background INTENT_ACTION_DISCONNECT (rig powered off / RFCOMM link dropped) runs disconnect() on another thread, which nulls the socket field. A CAT/TX worker already past the connected check in BluetoothSerialService.write() / BluetoothSerialSocket.write() then dereferenced the now-null socket (socket.write / socket.getOutputStream) -> NullPointerException. That NPE escaped BluetoothRigConnector.sendCommand's IOException-only catch and crashed the CAT worker on a background thread, leaving the link dead with no recovery — which surfaces as "connects, drops after ~10 s, no recovery" on Android 8 and 13/14. This is the check-then-act (TOCTOU) race the USB-serial path was already hardened against in CableSerialPort.writeIfOpen; the Bluetooth twin never received the analogous guard. Both write() layers now: - mark `socket` and `connected` volatile so connect / disconnect / read-loop / CAT-TX threads see consistent values under the Java memory model, and - snapshot the socket field ONCE into a local and route it through the new pure BluetoothSerialSocket.writeIfConnected(connected, sink, data) helper, which reports a torn-down link as the "not connected" IOException the caller already handles instead of NPEing. Added BluetoothSerialWriteTest (pure JVM, 4 cases) covering the guard, including the race case (connected==true but the snapshotted sink is null). Closes #781 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Update the per-contact row shown on the Activate tab (during an
activation) and on the historical activation detail screen so the
timestamp is a relative "5m ago" readout that stays fresh via a 30s
tick, and long-pressing the time surfaces a Toast with the original UTC
readout. Also add the QSO mode alongside band/grid on the second line
so the operator can tell at a glance which mode each contact was worked
on when the log carries a mix (FT8, MFSK, etc.).
Extracts the time-formatting decisions into three internal helpers so
the row remains a thin Composable wrapper:
- parseQsoUtcMs — mirrors PotaQsoWindow's HHMMSS normalization
(variable-width time_on, dropped leading zero) to turn qso_date +
time_on into a GMT epoch.
- formatQsoTimeAgo — buckets into just now / seconds / minutes /
hours / days, clamping negative deltas to "just now" so clock skew
can't produce "-2m ago".
- formatQsoTimeUtc — the HH:MMz long-press readout (formerly the
only display).
Closes #783
Issue #782 reported two UX problems that share a root cause of a hidden or stale UI affordance: 1. Tapping the waterfall/spectrum moved the blue tuning cursor but the red TX-bandwidth markers didn't follow, and were sometimes offset from the cursor. Both views computed the tap frequency inside onDraw() and read it back from getFreq_hz(), so an ACTION_UP that committed the base frequency read the previous frame's value. Extract SpectrumTouchMath so ColumnarView and WaterfallView share the same pixel<->Hz math, compute freq_hz eagerly in setTouch_x, draw the blue cursor at the pixel that maps back from that frequency (so the red +/- 25 Hz markers bracket it symmetrically), and have WaterfallScreen feed the touched frequency to the TX markers during the drag so the reds follow the blue live. 2. The CQ options bottom sheet (opened via long-press / "more" chevron) had no visible close affordance. Scrim tap, drag handle, and hardware Back all dismissed, but a tester who never discovered any of those saw no way out. Add an explicit close icon to FT8AFBottomSheet's header so every sheet that uses it — CQ options, band picker, hound setup, etc. — gets a plainly tappable Close. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to PR #774 (CI-V address hex/decimal fix). After that fix the app could COMMAND an IC-705 correctly, but the operator report on the issue was "Frequency update works, but there's no change in FT8AF if I change the QRG on the IC 705" — rig→app dial follow was still broken. Root cause: IcomRig has no CAT-side frequency poll of its own. Every other CAT rig class runs one (Yaesu38Rig, Yaesu39Rig, KenwoodTS590Rig, ElecraftRig, XieGuRig, TrUSDXRig, GuoHeQ900Rig, YaesuDX10Rig, Flex6000Rig, KenwoodKT90Rig, Wolf_sdr_450Rig, Yaesu2Rig, Yaesu2_847Rig, Yaesu38_450Rig, KenwoodTS2000Rig) — IcomRig was the odd one out, relying solely on CatLiveness's 3 s liveness probe. That watchdog stops hard on an 8 s quiet timeout, so any transient hush on the link (a coalesced retransmit, a slow login-recovery frame) leaves the app permanently out of sync with the rig's dial until the operator reconnects. Fix: IcomRig now runs its own 2 s frequency poll using the shared ReadTaskAction decision (connected + PTT-off → read frequency, PTT-on → defer to the 500 ms meter timer, disconnected → skip). onDisconnecting is overridden to cancel both this timer and the existing meter timer, so a reconnect via MainViewModel.connectRig doesn't leak the previous instance's Timer thread or double-poll after re-connect. Tests: IcomRigReadFreqPollTest covers all four tick decisions plus the readFreqFromRig frame bytes, using a CapturingConnector so no Timer or Robolectric is required. Full unit suite still passes (3491 tests, 0 failures). Closes #753 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #772 fixed RX over Bluetooth SCO on Android 8.1, but a tester then hit the mirror on the output side: with both audio input and output set to "Default" the SCO link comes up (so decoding works) yet TX audio never reaches the paired transceiver -- manually picking the same device's A2DP profile as the output makes TX work. Android keeps USAGE_MEDIA on the SCO speaker while the hands-free link is active, and the rig only listens on its A2DP music channel for the FT8 tone. AudioOutputRoutingPolicy (pure ints, unit-tested) says: if a Default output is being configured and the output device list contains BOTH TYPE_BLUETOOTH_A2DP and TYPE_BLUETOOTH_SCO, pick A2DP -- otherwise leave routing to the OS so a phone paired only for music has no behaviour change. FT8TransmitSignal.playViaAudioTrack() and playTuneTone() now consult the policy on the Default path and setPreferredDevice(A2DP) with a debug.log line so a future reader can see the override fire. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
--check-permissions shipped in #784 but nothing could invoke it: the workflow's only dispatch input was a dry_run boolean, so the one thing that answers "can this service account actually edit listings" was reachable only by downloading a private key and running the script by hand. Replace the boolean with a mode choice — dry-run (default), check-permissions, publish. A push to main still always publishes. The probe's exit code is a verdict rather than pass/fail, so the step reads it and annotates: 0 notice, 1 error naming the grant, anything else a warning saying the run reached no verdict and should be repeated. Guard the workflow against drifting from the CLI it calls: build_arg_parser() is now separate from main(), and the tests read the mode list straight out of the YAML and assert every mode maps to a real option string and has a case branch. Renaming a flag without updating the workflow used to surface only as a failed manual run against Play; it now fails the PR. Verified by mutation. The YAML is parsed by hand rather than with PyYAML to keep the suite stdlib-only, which is what lets the validate job install nothing. Also documents the constraint that bit us: workflow_dispatch does not appear until this file reaches the default branch, so the manual run is unavailable until the first promotion to main carries it there. 80 tests, up from 75.
- Give the probe verdict codes nothing else can produce. Exit 1 was reachable from a denied patch, bad credentials, unpublishable metadata, and any uncaught exception, so the workflow annotated all four as "you are missing the Play Console grant" — sending someone to fix a permission that was never wrong. Now EXIT_DENIED=3 and EXIT_INCONCLUSIVE=4, with generic failures on EXIT_ERROR=1 and the workflow mapping each separately. 2 is skipped deliberately: argparse exits 2 on a usage error, so the old inconclusive code collided with a mistyped flag. Failures that never reach the grant test are now normalized to inconclusive rather than escaping as tracebacks: opening the edit and reading the listings are both guarded. Outside probe mode they still raise, so a real publish fails loudly. - Make the drift guard read the workflow's own flags. It compared MODE_FLAGS against the parser — the table against itself — so a typo in the YAML (args+=(--check-permissons)) passed every test while the manual run failed. The case arms are now parsed out of the run step and checked against both the table and the parser's real option strings. Verified by mutation: that exact typo now fails two tests. 88 tests, up from 80.
Every error message, comment, and doc line said "Edit store listing, pricing & distribution". That label does not appear anywhere in the current Play Console permission list — someone following a failed probe would go hunting for a checkbox that no longer exists. The permission is now "Manage store presence", under the Store presence heading: "Edit your store listing and run store listing experiments; edit pricing; manage in-app products; edit distribution information and content ratings...". Same grant, current name. Confirmed against the live permission list for this app's Play Console, not from memory.
The prerequisite section asserted PLAY_SERVICE_ACCOUNT_JSON was set up for releases only and implied the first listing publish would 403. Checked against the app's Play Console permissions: "Manage store presence" is already ticked for FT8AF, so that was wrong. Keep the section — the grant is not implied by the release permission, and revoking it would break listing publishes while leaving releases working, which is a confusing failure — but state that it is granted rather than missing, and frame the probe as the check for when something changes rather than a step standing between here and the first publish.
The first real publish failed on the first locale: HTTPError: 404 Client Error: Not Found for url: .../edits/11895923857284451829/listings/ar Only en-US existed on the store, and PATCH is an update — the API has nothing to patch for a language with no listing yet. Choosing PATCH to preserve an existing promo video was right for updates and wrong for the 17 locales this change exists to create. Every test used a fake session that happily accepted a PATCH for an absent locale, so the suite agreed with the bug. upsert_listing() now PATCHes when Play already has the language and PUTs when it does not, with the language field in the PUT body since PUT replaces the whole resource. Nothing is lost by replacing: it only runs for languages that have never had a listing. run_check uses the same path, so the probe no longer 404s against a store whose only listing is the default language. The fake session grew a put() so the distinction is testable, and the two tests that asserted a PATCH for an absent locale — encoding the bug — now assert a create. Added a regression test for the exact store state that broke: en-US live, everything else missing. Nothing was published by the failed run; the edit was abandoned uncommitted. 93 tests, up from 88.
A merge of staging into main published the AAB straight to the Google Play production track, so a promotion PR shipped to users the moment it merged. Make that a deliberate, separate act instead. The main-push lane now leaves `play_track` empty: the merge still cuts the auto-bumped `android-v<x.y.z>` tag and the full GitHub Release (with the semver + release notes promoted from the staging build it's promoting), but the "Publish AAB to Play" step is skipped. Shipping to production is now done by pushing that `android-v*` tag, which re-runs the release lane in the `tag` lane and uploads to the production track. Unchanged: staging -> Play internal, and the android-v* tag -> Play production. - gate "Publish AAB to Play" / "Warn if Play publish failed" on a non-empty play_track - release summary says "not published" and names the tag to push - docs/release-pipeline.md updated to describe the manual production step Verified by running the "Compute version and release tag" step for all three lanes (production -> track empty + should_release=true, staging -> internal, tag -> production) and the release-summary step with an empty and a set track; `bash -n` clean on all 27 run blocks and the YAML parses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pyS6BHUwx1JTWdDijX4Be
dev already carries the same disconnect-race fix via #647, so this branch's re-implementation collided with it in BluetoothSerialSocket.java and BluetoothSerialWriteTest.java. Resolution: take dev's versions (which also keep the later frameFromRead EOF handling) and re-apply only this PR's Copilot-review comment clarifications about when `connected` is cleared, plus the test's StandardCharsets/comment tweaks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…licts) Both hunks were additive: dev added the redesigned TX-strip strings and the ruler-tick helpers (rulerTicks/RulerTick) at the same spots where this PR added the sheet_close string and displayTxFrequencyHz. Kept both sides in each file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
formatQsoTimeUtc validated hours and minutes but not seconds, so a row with time_on "144560" (60 s) was rejected by parseQsoUtcMs yet rendered as a plausible "14:45z" instead of the documented raw-value fallback. Check the normalized seconds too, and extend the out-of-range test with that case (and 59 s as the still-valid neighbour). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
A touch at exactly x == 0 is valid: touchToFreqHz clamps it to 100 Hz and the handlers commit it, but both views gated the blue cursor on touch_x > 0 as well, so the red markers moved to 100 Hz with no blue line between them. Gate on the selected frequency alone via a shared SpectrumTouchMath.hasTapCursor helper (the cleared state is setTouch_x(-1), which maps to -1), with tests for the edge touch and the cleared/unlaid states. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
- Only a 403 is a verdict about the grant. A 401 means the access token was not accepted, so the probe never reached the permission check; report it as INCONCLUSIVE with a credentials hint instead of sending someone to Play Console to fix a grant that was never tested. Test, constants comment, docs table and the workflow's exit-4 message updated. - The probe can PUT when Play has no listings, so the docstring, the log line, the workflow notice and docs no longer claim it always PATCHes; the log names the verb it actually used, and the tests assert it. - Dry-run tests now also assert nothing was PUT, so a regression that created missing locales during --dry-run cannot slip past. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
The manual ship runs the `tag` lane on an android-v* tag whose GitHub Release the main-merge run already created, but that lane never restored notes.txt: "Write release notes" sent a bare "FT8AF <version>" to Play as the what's-new and softprops, updating the existing release, replaced the promoted body with a placeholder. Add a step that reads the notes back out of the existing release's hidden markers for Play and keeps the body itself, handing it to softprops unchanged with generate_release_notes off so nothing is rewritten. A plain tag push with no release behaves as before. Runbook updated; the PR description now matches (workflow_dispatch, not a tag push). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
appHoldsScoSession() was queried after onBeforeTransmit()/onTuneKeyDown() had already called stopSco() and the worker had slept the PTT settle delay. The posted requestOff() flips the tracker to DISCONNECTED as soon as the main looper runs it, so in the normal CAT/RTS/DTR + Bluetooth case the live query answered "no" and the override never ran; with a busy main looper the answer became a race. Snapshot the tracker's answer in beginKeying() BEFORE stopSco() into a new TxScoLatch, read the latch from the TX path, and release it in endKeying() after the post-TX startSco(). A mid-slot message swap re-keys nothing, so the latch carries across it. TxScoLatchTest drives the real ScoLinkTracker through the stop-before-playback sequence as the ordering regression. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…8f27-53def5a0becd CAT via Bluetooyh is broken again
normalizeAdifTimeOn padded and then took the first six digits, so a value longer than ADIF's six-digit maximum was silently truncated into a plausible but wrong time: "14453099" read as 14:45:30 and the odd-width "1445309" as 01:44:53, and both the "ago" delta and the UTC tooltip showed them with full confidence. Reject anything over six digits so every caller takes its malformed-value fallback; regression test covers both parities. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
#788) - Both AndroidView listeners only forwarded a touch when the resolved frequency was positive, so the -1 an off-view drag resolves to never reached touchedFreqHz and the red TX markers stayed parked at the last on-view column until the timeout. Extract the routing into dispatchSpectrumTouch(): DOWN/MOVE always forward the frequency (the view has already hidden its cursor for that event), UP still commits only a valid one. Four pure tests in WaterfallScreenTest. - Add SpectrumViewTouchTest (Robolectric): lays out ColumnarView and WaterfallView for real and asserts setTouch_x updates getFreq_hz() immediately with no draw pass — the #782 regression on the actual views, plus the left-edge clamp, the -1 clear/off-view cases and the unlaid view. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…g test (PR #790) - Latch only when this keying actually pauses SCO for a Bluetooth rig (control-path keying with a rig and needControlSco()). A USB/network rig with a Bluetooth headset picked as its mic also holds a SCO link of ours, but its TX audio belongs on the rig, not the headset's A2DP. - The routing policy now takes the address of the device our SCO link is on — the mic's routed capture device, read at keying time before the stop (MicRecorder.routedScoInputAddress) — and only that device's A2DP endpoint is chosen. Without it, the SCO endpoints must all agree on one device; two hands-free devices with no word on which carries our link leave the routing to the OS instead of guessing the first pair. - TxScoLatch.keyDown() now performs the snapshot-then-stop order itself, taking the coordinator query and stopSco() as callables, so the ordering the TX path depends on is one tested call rather than two statements in beginKeying(). TxScoLatchTest hands it the real ScoLinkTracker as both the thing to snapshot and the thing to stop; reordering would fail it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…#791) - Move the probe's exit-code -> GitHub annotation mapping out of the workflow's bash `case` into publish_listings.probe_annotation(), relayed by a new --annotate-verdict RC flag (prints the ::level::message line, exits 0; the step still exits with the probe's own code). It is now unit tested per code — level, message, and that a non-verdict never names the grant — and a drift test asserts the workflow delegates to it and keeps no bash copy. - The CLI help and the workflow's mode-input comment no longer say the probe always PATCHes; both describe the PUT-on-empty-store path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…ad its release (PR #794) The restore step turned every `gh release view` failure — auth, rate limit, a transient API error — into "no release" and carried on, which would let the release step overwrite the promoted body with a placeholder and send a bare version string to Play. Now a workflow_dispatch run errors out on any lookup failure (the manual ship targets a release that must exist), and a tag push falls through to the no-release path only on a genuine "release not found"; any other error fails closed too. Exercised locally against the real repo: dispatch + missing tag -> exit 1, push + missing tag -> found=false, dispatch/push + bad token -> exit 1, existing release -> found=true with its notes and body intact. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
- Reconnects that fall between two ticks: CableConnector's auto-reconnect reopens within 500 ms, so a drop and reopen can both happen between 2 s polls and this timer never samples the link down — connectedSinceMs was inherited from the previous session and the next tick polled straight into the new connect handshake. BaseRigConnector now counts every link-up (connectionGeneration, bumped in its onConnected), and the tick starts a fresh settle window whenever that generation changed since the window was taken. Covered by reconnectBetweenTicks_earnsAFreshSettleWindow and connectionGeneration_countsEveryLinkUp. - Fixed-delay Timer.schedule() instead of scheduleAtFixedRate(): the latter catches up missed executions after a long tick, GC pause or device suspend and fires a burst of back-to-back CI-V reads; a polling backlog has no value. Matches the sibling rig pollers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…sses (PR #790) - AudioDeviceInfo.getAddress() exists only from API 28; calling it on the Android 8.1 device this steering exists for threw NoSuchMethodError (a linkage error no catch (Exception) stops) before any TX audio played, and in MicRecorder aborted the keying snapshot. Both callers now treat addresses as unknown below Pie, where the policy's single-pair fallback still performs the fix. - Bluetooth + VOX never latched: needControlSco() is true there but VOX does not take the control-path PTT branch, so SCO stays up and the TX audio is on the SCO route with no steering. TxScoLatch.keyDown now takes the two questions separately — "is this a Bluetooth rig TX" (latch) and "does this keying stop SCO" (run stopSco) — and MainViewModel passes needControlSco() for the first and the control-path condition for the second. Test: bluetoothRigOnVox_latchesWithoutStopping. - A known SCO address next to a blank one is two devices, not one: the blank endpoint may be the rig, so without the routed capture device the policy now leaves such an enumeration to the OS instead of steering to the named device. Tests for the mixed case with and without our link identified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…ode (PR #791) The early return for --annotate-verdict ran before the mode-exclusivity check, so `--check-permissions --annotate-verdict 3` skipped the probe and printed a caller-supplied denial with exit 0. The flag is now listed with the modes in that check, so any such combination is an argparse usage error (exit 2) before anything runs. Test covers all three modes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
#794) - main pushes no longer touch Play, so they leave the play-publish concurrency group (shared with play-listings.yml); a full main build no longer stalls staging uploads, manual ships or listing updates. - A manual dispatch now also fails closed when the existing release has no notes between the ft8af-notes markers (a release cut by a plain tag push, or an edited body): the documented notes restoration cannot silently degrade to shipping the bare version string. A tag push keeps the fallback. Exercised against the real repo: dispatch on android-v0.149 (no markers) -> exit 1, push on it -> found=true, dispatch on android-dev.1155 -> notes restored. - docs/release-pipeline.md: the manual step is scoped to releases cut by the staging -> main merge, and both production paths (manual run and a plain android-v* tag push) are documented in the Play Console note. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…#787) "just now" / "45s ago" / "5m ago" were hard-coded English, so the POTA contact row stayed English in every values-* locale while the rest of the screen is resource-backed. The pure helper now returns a bucket + count (qsoTimeAgo -> QsoAge) and the row resolves it through a string and four plurals (qsoAgeLabel over Resources), so translators own the wording and the singular/plural forms. Bucket tests converted; PotaQsoAgeLabelTest (Robolectric) covers the resource mapping and pins the default English. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…b0c9-d16f5d15cb7d Issues on Android-dev.1137
…s (PR #789) - BaseRigConnector.connected and BaseRig.isPttOn are written by the connector I/O callbacks / the TX path and read by the rig poll timers on their own threads with no happens-before edge; the generation counter alone could not fix that. Both are volatile now, pinned by a reflection test. - IcomRigReadFreqPollTest cancels the constructor's real 2 s / 500 ms Timers right after wiring the fake connector, so a slow test worker can no longer get a background tick appended between a manual tick and its assertion. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…ested adapter (PR #790) - java.util.function does not exist before API 24 and the core library is not desugared, so the BooleanSupplier/Supplier lambdas handed to TxScoLatch.keyDown would fail to load on Android 6 (minSdk 23) at keying time. Replaced with app-local LinkState/DeviceAddress interfaces. The same defect was already on dev in ScoLinkCoordinator's LongSupplier clock (#772), constructed with MainViewModel at app start; it now takes an app-local Clock. - AudioDeviceInfo.getAddress() can throw SecurityException on Android 12+ when BLUETOOTH_CONNECT is denied; treat that as an unknown address like the pre-Pie path instead of aborting the over. - The Android routing adapter is extracted into DefaultOutputRouting (enumeration -> policy -> setPreferredDevice through a Sink) and covered by DefaultOutputRoutingTest with Robolectric-built AudioDeviceInfo objects: steer + log, rejection log, no-SCO / no-BT / null no-ops, and the API 27 address gate. - Policy: when the capture side names our device but the only named SCO endpoint is a different device, the withheld-address fallback must not hand TX to the blank A2DP endpoint; leave it to the OS. Two new cases. - PR description updated to the emitted log strings, the latch, and the current file/test lists. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…utcomes (PR #791) The docstring called only 3 and 4 verdicts and everything else "never ran", which misclassified a successful probe (0 is the positive verdict) and contradicted 4 meaning no verdict. It now states the three outcomes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
) The tag lane's restore step carried its lookup-failure, event-type and marker-parsing branches in bash with no automated coverage. The decision now lives in .github/scripts/release_notes.py (stdlib only): the step runs gh and hands over the exit status, stderr and body, and the script decides — existing / missing release, dispatch versus tag push, and a strict marker parse that requires exactly one ordered start/end pair, so an end-before-start body can no longer ship trailing release text to Play as the what's-new. test_release_notes.py (17 cases) covers every branch and runs in the test job; the detect filter includes both files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
… control (PR #787) - The age string and four plurals existed only in values/, so every localized build fell back to English for the row text while the rest of the POTA screen is translated. Added to all 15 values-* files with each language's CLDR plural forms (one/few/many for cs/pl/ru/uk, six forms for ar, other-only for ja/ko/zh/in), plus the new accessibility label. PotaQsoAgeLabelTest resolves Spanish and every Russian quantity from their own files. - The time column was a long-press-only control: accessibility services announced an activate action that did nothing, and the target was two 11 sp labels. A tap now shows the UTC time too, both actions carry a localized label, and the target meets the 48 dp minimum. - Tick comments said "once a minute"; the refresh is every 30 s. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…bd27-84226dff2445 POTA: show mode, ago time, long-press for UTC in contact list
…95fd-e6a79d6bb04b Steer TX to A2DP when Default output shares a BT link with SCO
Let a manual listings run pick the mode, so the permission probe is reachable
…794) Claude's structured output permits an empty notes string and `jq -r` writes it as a bare newline, so a main release could carry blank markers and then be refused by the manual production ship. release_notes.py now has two subcommands: `restore` (as before) and `ensure-notes`, which "Write release notes" runs for every notes-producing lane before the markers are written — a blank notes.txt becomes the PR titles (whole lines, under Play's 500-char limit) or "Bug fixes and improvements.", with a ::warning. The AI step's bash fallback no longer duplicates that logic. Tests: ensure_notes / fallback_notes cases, the ensure-notes CLI, and a producer-to-consumer round trip through the marker template (25 total). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…heduler seam (PR #789) - BaseRig.connector is volatile: IcomRig starts its polls in the constructor and MainViewModel calls setConnector() afterwards from another thread, so the Timer thread could keep reading null. - IcomCivUdp.sendCivData/sendOpenClose are synchronized on the same monitor as sendTrackedPacket, so build + send + civSeq++ is atomic and two senders (dial poll, CAT-liveness poll, a PTT command) can no longer stamp the same CI-V sequence number on the WLAN path. - IcomRig schedules both polls through a PollScheduler seam (TimerScheduler in production). IcomRigPollSchedulingTest verifies the constructor schedules the dial poll fixed-delay with READ_FREQ_START_DELAY_MS / READ_FREQ_PERIOD_MS and the meter poll at fixed rate, that the scheduled task is the real tick, and that onDisconnecting cancels both — with no Timer and no sleeping. IcomRigReadFreqPollTest now builds the rig with a no-op scheduler instead of cancelling real Timers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…el (PR #794) - main is back in the play-publish concurrency group: it no longer publishes to Play, but its candidate step promotes the android-dev.* tag the staging run creates, and an unserialized main run could fetch tags before that prerelease exists and re-roll a fresh version and notes. The comment now states both concerns and the trade. - fallback_notes ignores the "(no pull-request merges in range)" placeholder the workflow writes for an empty PR list, so a blank notes file with no PRs gets DEFAULT_NOTES rather than the placeholder; a drift test checks the sentinel against android.yml, and the fallback warning no longer claims PR titles were used when there were none. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…nic settle (PR #789) - MainViewModel.onCleared() now runs baseRig.onDisconnecting(): the rig's poll Timers were only cancelled by connectRig()/disconnect, so a ViewModel cleared while connected left a non-daemon Timer polling a connector nobody owned. - IcomRig.setPTT publishes isPttOn() before the key-down goes out and only after the unkey is on the wire (finally-guarded), so a poll tick landing mid-dispatch can no longer read the dial ahead of the PTT-off command. IcomRigPttPollOrderingTest fires a settled tick inside the PTT dispatch and checks what went out, in order. - The settle window is measured with SystemClock.elapsedRealtime(), and "the connect-time push landed on this connection" is session state — the delivered stamp changed since the window was taken — rather than a wall-clock comparison a clock correction could fake or hide. Gate tests updated, plus one for a stamp that reads earlier after a backward step and one for a stale stamp that reads later after a forward step. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…es (PR #794) fallback_notes budgeted Python characters while the workflow truncates notes.txt with `head -c 500` (bytes), so multibyte PR titles could pass the budget and be cut mid-codepoint into invalid UTF-8 for Play. The fallback now counts UTF-8 bytes, and a new cap_notes() applies the same byte limit to every producer's notes (Claude's <=400-character answer can exceed 500 bytes in Cyrillic/CJK), cutting at a line break or else a codepoint boundary — ensure-notes trims real notes with a warning, and restore caps the copy it writes for Play while leaving the release body untouched. Multibyte regression cases for all three paths (31 tests). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…mp (PR #789) - MainViewModel.onCleared() now delegates the rig teardown to a static releaseRigOnClear(BaseRig) seam. The ViewModel cannot be constructed in a unit test (its constructor starts the audio recorder, the FT8 listener and thread pools), so MainViewModelRigCleanupTest exercises the hook through the seam with a recording rig, including the no-rig case. - GeneralVariables.operatorChoseDial() resets operatorDialDeliveredAtMs to 0 when the operator picks a new dial, so "the stamp changed" alone did not prove the connect-time write landed: a selection made inside the settle window would have opened the gate and read the rig's pre-command dial. The shortcut now requires a changed AND nonzero stamp. Gate tests cover the reset (window still expires on its own, the new selection's own delivery still clears it early) and a tick-level case for a selection during the window. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EnrELYBhBMQ9RAmQ9cQoyT
…b2ec-ec30c951c96d Android-1026: Freq Change won't work via network with IC-705
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
CI: stop publishing to Play production on staging → main merges
There was a problem hiding this comment.
🟡 Changes recommended
Brazilian Portuguese POTA resources are missing, and malformed UTF-8 release notes can remain unrepaired.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Promotes broad development work toward staging, improving radio control, Bluetooth audio routing, UI behavior, localization, and release automation.
Changes:
- Fixes waterfall tuning, POTA contact display, and bottom-sheet accessibility.
- Improves Icom polling and Bluetooth TX routing reliability.
- Updates Play listing and release-note workflows with expanded tests.
File summaries
| File | Description |
|---|---|
ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/waterfall/WaterfallScreenTest.kt |
Tests spectrum touch dispatch and marker frequency. |
ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/pota/PotaQsoTimeFormattingTest.kt |
Tests POTA time and contact formatting. |
ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/pota/PotaQsoAgeLabelTest.kt |
Tests localized relative-time labels. |
ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/FT8AFBottomSheetCloseButtonTest.kt |
Tests close-button visibility, action, and size. |
ft8af/app/src/test/java/com/k1af/ft8af/ui/SpectrumViewTouchTest.java |
Tests immediate view touch-frequency updates. |
ft8af/app/src/test/java/com/k1af/ft8af/ui/SpectrumTouchMathTest.java |
Tests spectrum coordinate conversion. |
ft8af/app/src/test/java/com/k1af/ft8af/rigs/IcomRigPttPollOrderingTest.java |
Tests polling exclusion during PTT transitions. |
ft8af/app/src/test/java/com/k1af/ft8af/rigs/IcomRigPollSchedulingTest.java |
Tests Icom poll scheduling and cancellation. |
ft8af/app/src/test/java/com/k1af/ft8af/MainViewModelRigCleanupTest.java |
Tests rig cleanup during ViewModel teardown. |
ft8af/app/src/test/java/com/k1af/ft8af/bluetooth/TxScoLatchTest.java |
Tests SCO state capture around transmission. |
ft8af/app/src/test/java/com/k1af/ft8af/bluetooth/DefaultOutputRoutingTest.java |
Tests Android output-routing integration. |
ft8af/app/src/test/java/com/k1af/ft8af/bluetooth/BluetoothSerialWriteTest.java |
Makes serial-write tests charset-explicit. |
ft8af/app/src/test/java/com/k1af/ft8af/bluetooth/AudioOutputRoutingPolicyTest.java |
Tests Bluetooth routing policy edge cases. |
ft8af/app/src/main/res/values/strings_compose.xml |
Adds close and POTA age resources. |
ft8af/app/src/main/res/values-zh-rTW/strings_compose.xml |
Adds Traditional Chinese POTA resources. |
ft8af/app/src/main/res/values-zh-rCN/strings_compose.xml |
Adds Simplified Chinese POTA resources. |
ft8af/app/src/main/res/values-uk/strings_compose.xml |
Adds Ukrainian POTA resources. |
ft8af/app/src/main/res/values-tr/strings_compose.xml |
Adds Turkish POTA resources. |
ft8af/app/src/main/res/values-ru/strings_compose.xml |
Adds Russian POTA resources. |
ft8af/app/src/main/res/values-pl/strings_compose.xml |
Adds Polish POTA resources. |
ft8af/app/src/main/res/values-nl/strings_compose.xml |
Adds Dutch POTA resources. |
ft8af/app/src/main/res/values-ko/strings_compose.xml |
Adds Korean POTA resources. |
ft8af/app/src/main/res/values-ja/strings_compose.xml |
Adds Japanese POTA resources. |
ft8af/app/src/main/res/values-it/strings_compose.xml |
Adds Italian POTA resources. |
ft8af/app/src/main/res/values-in/strings_compose.xml |
Adds Indonesian POTA resources. |
ft8af/app/src/main/res/values-fr/strings_compose.xml |
Adds French POTA resources. |
ft8af/app/src/main/res/values-es/strings_compose.xml |
Adds Spanish POTA resources. |
ft8af/app/src/main/res/values-cs/strings_compose.xml |
Adds Czech POTA resources. |
ft8af/app/src/main/res/values-ar/strings_compose.xml |
Adds Arabic POTA resources. |
ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/waterfall/WaterfallScreen.kt |
Synchronizes touch cursors and TX markers. |
ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/pota/PotaScreen.kt |
Adds relative times and UTC interaction. |
ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/FT8AFBottomSheet.kt |
Adds an accessible close control. |
ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java |
Exposes routed SCO device address. |
ft8af/app/src/main/java/com/k1af/ft8af/ui/WaterfallView.java |
Updates touch and marker geometry. |
ft8af/app/src/main/java/com/k1af/ft8af/ui/SpectrumTouchMath.java |
Centralizes spectrum coordinate math. |
ft8af/app/src/main/java/com/k1af/ft8af/ui/ColumnarView.java |
Uses shared eager touch conversion. |
ft8af/app/src/main/java/com/k1af/ft8af/rigs/IcomRig.java |
Adds safe dial polling and cleanup. |
ft8af/app/src/main/java/com/k1af/ft8af/rigs/BaseRig.java |
Makes cross-thread rig state visible. |
ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java |
Integrates SCO latching and rig teardown. |
ft8af/app/src/main/java/com/k1af/ft8af/icom/IcomCivUdp.java |
Serializes CI-V sequence allocation. |
ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java |
Applies Bluetooth output overrides. |
ft8af/app/src/main/java/com/k1af/ft8af/connector/BaseRigConnector.java |
Tracks connection generations safely. |
ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/TxScoLatch.java |
Captures SCO routing state per TX. |
ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/ScoLinkCoordinator.java |
Replaces API-incompatible functional interface. |
ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/DefaultOutputRouting.java |
Bridges routing policy to AudioTrack. |
ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/BluetoothSerialSocket.java |
Clarifies disconnect-race handling. |
ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/AudioOutputRoutingPolicy.java |
Selects safe A2DP output endpoints. |
docs/store-listings.md |
Documents listing modes and permissions. |
docs/release-pipeline.md |
Documents manual Play production promotion. |
.github/workflows/play-listings.yml |
Adds selectable listing workflow modes. |
.github/scripts/test_release_notes.py |
Tests release-note restoration and fallback. |
.github/scripts/release_notes.py |
Implements release-note restoration and limits. |
.github/scripts/publish_listings.py |
Adds listing creation and permission verdicts. |
Review details
- Files reviewed: 56/60 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+213
to
+221
| def run_ensure_notes(args): | ||
| notes = None | ||
| if os.path.exists(args.notes): | ||
| with open(args.notes, encoding="utf-8", errors="replace") as f: | ||
| notes = f.read() | ||
| ensured, used_fallback = ensure_notes(notes, os.environ.get(args.pr_list_env, "")) | ||
| if used_fallback or ensured != notes: | ||
| with open(args.notes, "w", encoding="utf-8", newline="\n") as f: | ||
| f.write(ensured) |
Comment on lines
+384
to
+385
| <!-- POTA contact row: time since the QSO. %d is the count in that unit. --> | ||
| <string name="pota_qso_age_just_now">just now</string> |
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.
No description provided.