Steer TX to A2DP when Default output shares a BT link with SCO - #790
Conversation
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>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## dev #790 +/- ##
============================================
+ Coverage 42.34% 42.40% +0.06%
- Complexity 226 227 +1
============================================
Files 267 267
Lines 31834 31862 +28
Branches 3650 3649 -1
============================================
+ Hits 13479 13510 +31
+ Misses 18097 18087 -10
- Partials 258 265 +7
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
🟡 Changes recommended
Device types alone cannot establish an active SCO link or matching A2DP device, risking transmission to an unrelated Bluetooth sink.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Steers default TX audio toward Bluetooth A2DP when SCO is detected, addressing Android 8.1 transmission routing.
Changes:
- Adds a pure-Java output-routing policy.
- Applies routing to FT8 and tune playback.
- Adds eight policy tests.
File summaries
| File | Description |
|---|---|
AudioOutputRoutingPolicy.java |
Selects A2DP when A2DP and SCO types coexist. |
FT8TransmitSignal.java |
Applies the override to both TX paths. |
AudioOutputRoutingPolicyTest.java |
Tests policy decisions and constants. |
Review details
- Files reviewed: 3/3 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.
There was a problem hiding this comment.
🟡 Changes recommended
The SCO state is normally cleared before playback checks it, preventing the routing override from activating.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Balanced
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
There was a problem hiding this comment.
🟡 Changes recommended
Routing can still select an unrelated Bluetooth endpoint and incorrectly affects USB rigs using a Bluetooth microphone.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 3
- Review effort level: Balanced
…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
There was a problem hiding this comment.
🟡 Changes recommended
Critical API 27 compatibility failures and unresolved Bluetooth routing cases block approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java:314
- The pure policy tests never execute this new Android-facing address lookup, so they cannot catch platform API-boundary failures such as the API-27 crash above. Add a Robolectric test for the new extraction path on SDK 27 (unknown address/fallback) and SDK 28+ (SCO address returned), including non-SCO and blank-address cases.
public synchronized String routedScoInputAddress() {
- Files reviewed: 7/7 changed files
- Comments generated: 4
- Review effort level: Balanced
…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
There was a problem hiding this comment.
🟡 Changes recommended
The API 23 compatibility failure and unresolved routing, permission-handling, and test-coverage issues must be addressed.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java:3346
- The pure policy and latch are tested, but no test executes this new Android routing adapter, so the wiring from enumerated outputs and the TX latch into
setPreferredDevice()(including the rejection diagnostic) can regress unnoticed. The project requires every new helper/branch to have unit coverage; please extract injectable device/route collaborators or add a focused Robolectric test for this helper.
ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/AudioOutputRoutingPolicy.java:170
- When
activeScoAddressis known but its A2DP endpoint is not found, this fallback can discard contradictory known output identity. For[A2DP(blank), SCO(OTHER)]with the active SCO addressRIG, it selects the blank A2DP endpoint even though the only enumerated SCO endpoint is another device, potentially sending TX to that device. Preserve the all-addresses-withheld fallback, but do not use it when a known active address conflicts with any known output SCO address; add this partial-redaction case to the policy tests.
boolean addressesUninformative =
!isKnownAddress(addressAt(deviceAddresses, firstA2dpIdx)) || !anyScoAddressKnown;
if (a2dpCount == 1 && scoCount == 1 && addressesUninformative) {
return firstA2dpIdx;
ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java:3356
- The PR's caveat and on-device test instruct the tester to search for
default output steered to A2DP (SCO link present), but this success message is now...(app SCO session up)and rejection has its own message. Following the documented check will therefore falsely indicate that the override did not fire; update the PR description and its test expectations to the emitted strings (and refresh the stale file/test counts).
GeneralVariables.fileLog(applied
? "playFT8Signal: default output steered to A2DP (app SCO session up)"
: "playFT8Signal: A2DP steering REJECTED by setPreferredDevice;"
+ " TX audio stays on the OS route");
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Balanced
…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
|
Also addressed the three suppressed comments from the latest review in 343d137:
|
Closes #759
What changed
Follow-up to PR #772. That change made the RX side of Bluetooth work reliably on Android 8.1 (the SCO link is now brought up and the mic re-routed to it), and a tester confirmed decoding worked -- but reported a mirror bug on the TX side:
Root cause. On Android 8.1, once the SCO (hands-free) link is up for the mic, the OS keeps the
USAGE_MEDIAstream on the SCO speaker path too. The paired transceiver only listens for the FT8 tone on its A2DP music channel, so TX audio never leaves the phone -- exactly the "audio received, not transmitted" report. Manually picking the paired device's A2DP profile as the output moves TX back to A2DP and cures it.Fix.
AudioOutputRoutingPolicy(pure ints/Strings, unit-tested): when the user chose "Default" output, our own SCO link for the rig was up when the over was keyed, and the output list has an A2DP endpoint on the same Bluetooth device as that link, steer TX to it. It leaves routing to the OS for a rig on USB/network with a paired car kit nearby, for an A2DP endpoint on a different device, and whenever two hands-free devices are enumerated and it cannot tell which carries our link.TxScoLatch: the keying path (MainViewModel.beginKeying) stops SCO before PTT, and the TX worker only asks after the PTT settle delay -- by then the tracker already says "down". So keying snapshots the link state (and the address of the device the mic is captured from over SCO) into the latch beforestopSco(), and the TX path reads the snapshot. The latch engages for any Bluetooth-rig TX (needControlSco(), so VOX included) and stops SCO only on the control-path keying that always did.DefaultOutputRouting: the Android adapter from the enumerated outputs to the policy toAudioTrack.setPreferredDevice, with the API < 28 / denied-BLUETOOTH_CONNECTaddress gating. Both TX playback paths inFT8TransmitSignal(playViaAudioTrack()andplayTuneTone()) call it on the Default-output branch.debug.logsays what happened on each transmit:playFT8Signal: default output steered to A2DP (app SCO session up)when the route was applied, orplayFT8Signal: A2DP steering REJECTED by setPreferredDevice; TX audio stays on the OS routewhen the framework refused it. No line at all on that branch means the policy left routing to the OS.Also fixed on the way:
ScoLinkCoordinator(merged in #772) took ajava.util.function.LongSupplierclock, which does not exist before API 24 and would have failed to load on Android 6 (minSdk 23) while constructingMainViewModel; it now uses an app-localClockinterface, as the latch does.Files changed:
ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/AudioOutputRoutingPolicy.java(new)ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/TxScoLatch.java(new)ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/DefaultOutputRouting.java(new)ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/ScoLinkCoordinator.java(API 23 clock)ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java(keying snapshot, TX accessors)ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java(routed SCO address)ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java(both TX playback paths)AudioOutputRoutingPolicyTest(27 cases),TxScoLatchTest(6, drives the realScoLinkTrackerthrough the snapshot-then-stop order),DefaultOutputRoutingTest(7, Robolectric, realAudioDeviceInfoobjects and a capturing sink)Caveat
Like #772 this is verified with unit tests, not on an Android 8.1 device. If the tester still sees TX not reaching the rig, a fresh
debug.logwill now say eitherdefault output steered to A2DP (app SCO session up)(the override fired -- something further downstream is broken),A2DP steering REJECTED by setPreferredDevice(the framework refused the route), or nothing at all on that branch (the policy left routing to the OS -- most likely only one of A2DP/SCO enumerated, or our SCO link was not up at keying).How to test
ft8af/run./gradlew testDebugUnitTest --tests 'com.k1af.ft8af.bluetooth.*'(JDK 17). Windows:cmd.exe /c "gradlew.bat testDebugUnitTest --tests com.k1af.ft8af.bluetooth.*".debug.logcontainsplayFT8Signal: default output steered to A2DP (app SCO session up)on each transmit; RX behaviour from Bluetooth SCO: track link state, retry, and rebuild the mic on connect (Android 8.x RX dead, #759) #772 is unchanged.