Skip to content

Steer TX to A2DP when Default output shares a BT link with SCO - #790

Merged
patrickrb merged 6 commits into
devfrom
optio/task-fc437d41-cbb9-4e40-95fd-e6a79d6bb04b
Sep 2, 2026
Merged

Steer TX to A2DP when Default output shares a BT link with SCO#790
patrickrb merged 6 commits into
devfrom
optio/task-fc437d41-cbb9-4e40-95fd-e6a79d6bb04b

Conversation

@patrickrb

@patrickrb patrickrb commented Aug 31, 2026

Copy link
Copy Markdown
Owner

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:

If I set audio input and output to "Default", audio received, but not transmitted to the transceiver. However, if I leave the audio input as "Default" and select a device with the A2DP profile for the audio output, audio is transmitted and received correctly.

Root cause. On Android 8.1, once the SCO (hands-free) link is up for the mic, the OS keeps the USAGE_MEDIA stream 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 before stopSco(), 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 to AudioTrack.setPreferredDevice, with the API < 28 / denied-BLUETOOTH_CONNECT address gating. Both TX playback paths in FT8TransmitSignal (playViaAudioTrack() and playTuneTone()) call it on the Default-output branch.

debug.log says what happened on each transmit: playFT8Signal: default output steered to A2DP (app SCO session up) when the route was applied, or playFT8Signal: A2DP steering REJECTED by setPreferredDevice; TX audio stays on the OS route when 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 a java.util.function.LongSupplier clock, which does not exist before API 24 and would have failed to load on Android 6 (minSdk 23) while constructing MainViewModel; it now uses an app-local Clock interface, 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)
  • tests: AudioOutputRoutingPolicyTest (27 cases), TxScoLatchTest (6, drives the real ScoLinkTracker through the snapshot-then-stop order), DefaultOutputRoutingTest (7, Robolectric, real AudioDeviceInfo objects 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.log will now say either default 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

  • Unit tests: from ft8af/ run ./gradlew testDebugUnitTest --tests 'com.k1af.ft8af.bluetooth.*' (JDK 17). Windows: cmd.exe /c "gradlew.bat testDebugUnitTest --tests com.k1af.ft8af.bluetooth.*".
  • On-device (Android 8.1 with a BT-audio transceiver, Default I/O selected): confirm TX now reaches the rig and debug.log contains playFT8Signal: 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.

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

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 42.40%. Comparing base (6ab072f) to head (343d137).
⚠️ Report is 18 commits behind head on dev.

Additional details and impacted files

Impacted file tree graph

@@             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     
Flag Coverage Δ
android 17.05% <ø> (+0.15%) ⬆️
native 9.93% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.
see 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

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.

Comment thread ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java Outdated
Comment thread ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/AudioOutputRoutingPolicy.java Outdated

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

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

Comment thread ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java Outdated
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

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

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

Comment thread ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java Outdated
Comment thread ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/AudioOutputRoutingPolicy.java Outdated
Comment thread ft8af/app/src/test/java/com/k1af/ft8af/bluetooth/TxScoLatchTest.java Outdated
…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

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

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

Comment thread ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java Outdated
Comment thread ft8af/app/src/main/java/com/k1af/ft8af/wave/MicRecorder.java Outdated
Comment thread ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java
…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

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

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 activeScoAddress is 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 address RIG, 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

Comment thread ft8af/app/src/main/java/com/k1af/ft8af/bluetooth/TxScoLatch.java Outdated
Comment thread ft8af/app/src/main/java/com/k1af/ft8af/ft8transmit/FT8TransmitSignal.java Outdated
…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
@patrickrb

Copy link
Copy Markdown
Owner Author

Also addressed the three suppressed comments from the latest review in 343d137:

  • Untested Android adapter — the enumeration → policy → setPreferredDevice wiring is now DefaultOutputRouting.apply(outputs, scoHeld, scoAddress, Sink), and DefaultOutputRoutingTest (Robolectric, real AudioDeviceInfo objects via AudioDeviceInfoBuilder, a capturing sink) covers the steer + LOG_STEERED, the LOG_REJECTED path, the no-SCO / no-Bluetooth / null no-ops, and the API 27 address gate. FT8TransmitSignal only supplies the real track and fileLog.
  • Withheld A2DP address contradicting a named SCO[A2DP(blank), SCO(OTHER)] with our link on RIG now leaves routing to the OS; [A2DP(blank), SCO(RIG)] still uses the single-pair fallback. Two new policy tests (27 total).
  • Stale PR description — rewritten: the emitted log strings, the latch, the adapter, the coordinator clock fix, and the current file/test lists.

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.

🔵 Needs a closer look

Bluetooth routing behavior remains unverified on the target Android 8.1 device.

Review details
  • Files reviewed: 10/10 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@patrickrb
patrickrb merged commit 1430411 into dev Sep 2, 2026
18 checks passed
@patrickrb
patrickrb deleted the optio/task-fc437d41-cbb9-4e40-95fd-e6a79d6bb04b branch September 2, 2026 19:14
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