Skip to content

merge dev -> staging - #795

Merged
patrickrb merged 53 commits into
stagingfrom
dev
Sep 2, 2026
Merged

merge dev -> staging#795
patrickrb merged 53 commits into
stagingfrom
dev

Conversation

@patrickrb

Copy link
Copy Markdown
Owner

No description provided.

Optio Agent and others added 30 commits August 31, 2026 19:19
… 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
patrickrb and others added 23 commits September 2, 2026 13:02
- 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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>
@patrickrb
patrickrb merged commit fd67902 into staging Sep 2, 2026
39 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants