Skip to content

Add QRP Labs QMX via the hamlib backend - #925

Open
laniol8926 wants to merge 55 commits into
patrickrb:devfrom
laniol8926:feat/qmx-hamlib-rig
Open

laniol8926 wants to merge 55 commits into
patrickrb:devfrom
laniol8926:feat/qmx-hamlib-rig

Conversation

@laniol8926

@laniol8926 laniol8926 commented Oct 20, 2026 •

Copy link
Copy Markdown

Summary

  • The rig catalog (rigaddress.txt) had no entry for the QMX at all, so it never showed up as a selectable rig in Settings.
  • Its sibling QDX is listed using the generic Kenwood TS-590/480 command set, but that path sets MD2 (SSB-USB voice mode) rather than DATA mode + the TX-rise-threshold tuning the QMX specifically needs to transmit — the same bug danleetw/FT8TW's native app had to hand-patch around (MD6;Q41;).
  • FT8AF already vendors and statically compiles hamlib 4.7.2, which has a dedicated QRP Labs QMX backend (qrplabs_qmx_caps / RIG_MODEL_QRPLABS_QMX), exposed through the existing HamlibRig bridge — the same mechanism already used for "Yaesu FT-891 (Hamlib)". This adds the matching catalog line: 0x809 = RIG_MAKE_MODEL(RIG_KENWOOD, 57), hamlib's real QMX model ID, at its minimum supported baud rate (9600).
  • Per QRP Labs' own current QMX firmware guidance, the native hamlib definition is the recommended path over Kenwood emulation, which matches this change.
  • Appended at the end of the list (after ICOM IC-7300MK2), per RigAddressAssetTest's documented append-only invariant — the selected rig is persisted as a raw list index, so inserting it mid-file would silently reassign every existing user's selected rig on upgrade.

Status (updated after real-hardware testing — see PR comment thread for the full narrative)

Confirmed working on real QMX hardware (Android 15, Motorola Edge 2022; CAT also confirmed earlier on Android 13):

  • CAT control, frequency set/read, and PTT all work correctly through the hamlib QMX backend.
  • Mode stays in Digital (MD6;) through a frequency change.
  • RX decodes real FT8 traffic — 7+ consecutive decode cycles with real callsigns.

Bugs found and fixed along the way (commit-by-commit detail is in the PR thread):

  1. Wrong mode string (PKTUSB → invalid MDD;) produced no CAT effect on the QMX; fixed with a QMX-specific override (RTTY → MD6;) that no longer applies to other hamlib rigs (e.g. FT-891 keeps PKTUSB).
  2. The hamlib model ID was derived from a separate, now-removed config key that could go stale or hold a non-hamlib value; it's now derived fresh from the selected rig entry at connect time.
  3. A structural bug in the native bridge dropped the radio's handshake replies because the handle was published only after rig_open() returned; fixed by splitting create/open so the handle is live before the handshake runs.
  4. Two related native-bridge races (use-after-free on a failed open, double-free/leak on a disconnect overlapping an open) fixed by moving all handle release through a single releaseHandle() on the rig's own worker thread, guarded by a read/write lock against the serial feed path.
  5. RX was completely silent due to an unrelated bug in MicRecorder: requesting ENCODING_PCM_FLOAT from a USB input silently produces unusable samples on this phone's MediaTek audio HAL. Fixed by capturing 16-bit PCM and converting to float in software. (This fix isn't QMX-specific — see "Known gaps" below.)
  6. A QMX-specific I/Q-mode-off command (Q90;) is now sent on connect, matching a fix in danleetw/FT8TW for the same symptom on this radio family.

Known gaps, still open:

  • TX audio is underdriven (low) on the QMX — the rig keys correctly, CAT and mode are both fine, but audio level needs investigation. Not yet root-caused.
  • Finding 5's RX-capture fix (PCM16 instead of float, plus two related MicRecorder lifecycle fixes) touches every rig's audio capture, not just the QMX's. It's been smoke-tested on the QMX but not against other hardware (IC-7300/705, CM108/Digirig, Bluetooth SCO) — per review feedback, that fix should probably ship as its own separate PR rather than ride in with the QMX catalog entry.
  • No local build/unit-test execution was possible on the Linux ARM board this work was primarily developed on (aapt2 is x86_64-only there); all real builds/on-device testing were done via a Windows machine with Android Studio, connected over Claude Code's Remote Control feature.

Test plan

  • Let CI run
  • Build an APK and install on a real device
  • Select "Qrp Labs QMX (Hamlib)" in Settings and confirm it connects over USB CAT
  • Confirm frequency read/set and PTT work as expected
  • Confirm RX decodes real FT8 traffic
  • Confirm TX audio drive is adequate (currently known low/underdriven)

🤖 Generated with Claude Code

https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf

@codecov

codecov Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 6.89655% with 54 lines in your changes missing coverage. Please review.
✅ Project coverage is 41.92%. Comparing base (f702100) to head (27b31fd).

Files with missing lines Patch % Lines
...dio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt 0.00% 27 Missing ⚠️
...lin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt 0.00% 21 Missing ⚠️
...n/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt 0.00% 6 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff              @@
##                dev     #925      +/-   ##
============================================
+ Coverage     41.70%   41.92%   +0.21%     
  Complexity      252      252              
============================================
  Files           280      280              
  Lines         34002    34119     +117     
  Branches       4076     4091      +15     
============================================
+ Hits          14180    14303     +123     
+ Misses        19531    19504      -27     
- Partials        291      312      +21     
Flag Coverage Δ
android 18.19% <6.89%> (+0.52%) ⬆️
desktop 63.95% <ø> (ø)
ios 97.71% <ø> (ø)
native 12.70% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...kotlin/radio/ks3ckc/ft8af/ui/logbook/AdifImport.kt 100.00% <100.00%> (ø)
...n/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt 2.27% <0.00%> (-0.01%) ⬇️
...lin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt 6.74% <0.00%> (-0.05%) ⬇️
...dio/ks3ckc/ft8af/ui/settings/RadioAudioSettings.kt 0.25% <0.00%> (-0.01%) ⬇️

... and 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.

@laniol8926

Copy link
Copy Markdown
Author

@patrickrb — flagging this directly in case it gets buried under the Codecov bot comment.

This change is small (one line in rigaddress.txt) and verified correct at the source level against the hamlib headers/binary already vendored in this repo, but I couldn't get a final APK out of my build environment to actually test it — I'm on an aarch64 Linux board, and the AGP-bundled aapt2 for Linux is x86_64-only, with no safe emulation path available here (see the PR description for what I tried and why I backed off).

Would you be able to either:

  1. Build a debug APK from this branch yourself and test it against a QMX, or
  2. If you're set up to build it but don't have a QMX handy, publish a debug build somewhere (a GitHub Release / Actions artifact) so I can test it directly on real hardware and report back?

No rush — happy to wait on whichever's easier on your end.

The rig catalog (rigaddress.txt) had no entry for the QMX at all, so it
never showed up as a selectable rig. Its sibling QDX is listed using the
generic Kenwood TS-590/480 command set, but that path sets MD2 (SSB-USB
voice mode) rather than the DATA mode + TX-rise-threshold tuning the QMX
specifically needs to transmit -- same bug danleetw/FT8TW's native app
had to hand-patch around.

FT8AF already vendors and statically compiles hamlib 4.7.2, which has a
dedicated QRP Labs QMX backend (qrplabs_qmx_caps / RIG_MODEL_QRPLABS_QMX),
exposed through the existing HamlibRig bridge -- the same mechanism
already used for "Yaesu FT-891 (Hamlib)". This adds the matching catalog
line: 0x809 = RIG_MAKE_MODEL(RIG_KENWOOD, 57), hamlib's real QMX model ID,
at its minimum supported baud rate (9600).

Per QRP Labs' own current QMX firmware guidance, the native hamlib
definition is the recommended path over Kenwood emulation, which matches
this change.

Appended at the end of rigaddress.txt (after ICOM IC-7300MK2), per
RigAddressAssetTest's documented append-only invariant -- the selected
rig is persisted as a raw list index, so inserting anywhere else would
silently reassign every existing user's selected rig on upgrade. Added
a matching GOLDEN_ORDER entry, moved the "last entry" pin from the MK2
test to this one, and added a dedicated test (qmxHamlib_isListedWith
HamlibModelId) mirroring the existing ic7300mk2_isListedWithIcomDefaults
pattern.

Verified: builds cleanly through resource merge, manifest processing,
and Java/Kotlin compilation on this checkout; the new/changed test
assertions were traced by hand against RigNameList's parser (confirmed
"809" parses as hex -> 2057 = 0x809) since this environment can't
execute them. Could not produce a final APK or run the JVM unit tests
locally -- the AGP-bundled aapt2 for Linux is x86_64-only and this build
machine is aarch64 (processDebugResources, a dependency of both
assembleDebug and testDebugUnitTest, needs it to compile AAR resources).
qemu-user-static emulation was attempted but blocked by an unrelated,
unsafe apt dependency conflict in this machine's repo configuration
(would have required upgrading libc6 system-wide, so it was not forced
through). No QMX hardware was available to test TX/RX against either.
All three are red flags a reviewer with access to any of them should
check before merge -- CI (real x86_64 runners) is this PR's actual first
verification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
@laniol8926

Copy link
Copy Markdown
Author

Tested this on real QMX hardware (arm64 debug build from this branch, sideloaded).

CAT control works — confirms the fix itself:
```
serial.prepare: matched 0483:a34c deviceId=2005 port=0
serial.open: OK port=0 baud=9600
setOperationBand: setting freq=7074000 (rig.getFreq=7074000)
```
Frequency set/read-back matches via the hamlib QMX backend. VID `0483` = STMicroelectronics, consistent with the QMX's STM32-based USB implementation.

But TX audio write fails — separate, pre-existing bug, not introduced by this PR's diff:
```
QSO: TX slot=1 order=6 msg=[CQ AI5II EM71]
playFT8Signal: TX path branch: audioOutputDeviceId=-1 usbAudioOutputVidPid=0483:A34C volume=100% autoVolume=false
playFT8Signal: using USB audio (direct) output
UsbAudioDevice: kernel audio driver detach via AudioControl iface 2: OK
playViaUsbAudio: device opened, output activated at 48000 Hz
UsbAudioDevice: trying libusb native write fd=209 iface=4 alt=1 ep=0x03 maxPkt=300 bytes=2426880 outputRate=48000 ch=2
UsbAudioDevice: libusb native write FAILED (rc=-1 IO), trying UsbRequest fallback
playViaUsbAudio: writeAudio returned FAILED — TX DROPPED, no audio sent this cycle (rig keyed but silent)
```
PTT fires (rig keys) but the audio transfer itself fails with a low-level libusb I/O error, so nothing actually transmits.

Also saw the whole USB connection to the QMX fully detach and reattach twice during the session, independent of the TX attempt:
```
CAT drop: IOException: USB get_status request failed [kind=TRANSIENT action=RECONNECT attempt=0]
usbDetach: vid=483 pid=a34c
CAT auto-reconnect: device left the bus, stopping (attach broadcast will restart)
```

I initially suspected old-phone/cable flakiness (this is a 2015 Moto G 3rd Gen), but ruling that out: both the cable and this exact phone have already done reliable, sustained live QMX work — same cable is the one in daily use with a different always-on QMX setup, and this same phone has already done live FT8 decoding over USB with a different native Android FT8 app with no equivalent dropouts. So this looks like something specific to FT8AF's direct-libusb audio path or its interaction with the CAT connection on this device, not the hardware.

Given CAT itself works end to end, I think this PR's change is sound — the TX-audio-write and USB-detach issues look like a separate, pre-existing bug surfaced by QMX now being reachable at all. Happy to pull more of debug.log or try narrower repros if useful.

@laniol8926

Copy link
Copy Markdown
Author

One more real-hardware finding: changing bands in the app did not actually retune the radio. Confirmed by direct observation (watching the QMX itself), not just the log — the first band change (to 7074000/40m) did take effect, but a later change to 14074000/20m did not; the radio stayed on 40m.

I can't fully root-cause this from `debug.log` alone, and want to flag why precisely: `HamlibRig.setFreqToRig()` submits `HamlibNative.nativeSetFreq()` to an async worker and only logs a failure via `Log.w` (logcat) if `rc != 0` — that line never reaches `fileLog()`/`debug.log`. I don't have `adb`/logcat access in this environment, so I can't see whether the native call actually returned a nonzero `rc` for that second band change or silently no-op'd for some other reason. The `debug.log` lines around it:
```
bandSelect: index=20, band=14074000, controlMode=1, rigConnected=true
setOperationBand: sending USB mode, then freq=14074000 in 800ms (controlMode=1)
setOperationBand: setting freq=14074000 (rig.getFreq=7074000)
```
...only show the app's own intent/cached state, not hamlib's actual return code — so this is a real symptom I can confirm, but not something I can pin down further without logcat. If it'd help, `fileLog()`-ing the `rc` from `nativeSetMode`/`nativeSetFreq` (not just `Log.w`) would make this kind of QMX-hamlib issue debuggable from the phone alone next time, since logcat access isn't always available.

@laniol8926

Copy link
Copy Markdown
Author

Found the precise mechanism behind "transmitting but no audio reaches the radio" (and probably the earlier "good audio level but no waterfall/decodes" too): after a USB detach/reattach, the serial/CAT side reconnects correctly, but the USB audio side silently falls back to the phone's own mic/speaker and never reacquires the QMX.

Sequence from `debug.log`:
```
usbDetach: vid=483 pid=a34c
MicRecorder: reinit AudioRecord(VOICE_RECOGNITION) <- phone's own mic, not the radio
MicRecorder: stereo capture open, channel select=1
...
onNewIntent delayed: rigConnected=true <- CAT reconnected fine
```
No `openUsbAudioInput`/`UsbAudioDevice` lines appear anywhere after that detach — compare to every other reconnect earlier in the same log, which all show:
```
openUsbAudioInput: found device 0483:A34C name=QMX Transceiver
UsbAudioDevice: kernel audio driver detach via AudioControl iface 2: OK
UsbAudioDevice: trying libusb native capture fd=... iface=3 alt=1 ep=0x83 ...
UsbAudioDevice: libusb capture started OK
```
Then on the next TX attempt:
```
playFT8Signal: TX path branch: audioOutputDeviceId=54 usbAudioOutputVidPid=0000:0000 volume=100% autoVolume=false
playFT8Signal: using AudioTrack output (Android default sink)
```
`usbAudioOutputVidPid=0000:0000` confirms it: the app no longer thinks the QMX is an audio device at all, so it routes TX through Android's default sink (the phone speaker) instead of over USB. No error is shown to the user — the app just reports "transmitting" normally the whole time.

So the earlier "libusb native write FAILED (rc=-1 IO)" report and this one may be two different faces of the same underlying USB-audio-device-tracking issue (one still attempting and failing the libusb write, this one having given up on USB audio entirely and not noticing) — or two separate bugs. Either way, the common thread across all three things reported on this PR is: the USB audio side of the connection is noticeably less resilient to reattach/reconnect events than the CAT/serial side, and it fails silently rather than surfacing to the user or retrying.

@laniol8926

Copy link
Copy Markdown
Author

Follow-up on the band-retune issue above, with a harder confirmation: watching the QMX's own display directly (not just the app or the log), the radio's actual frequency stayed at 7074 (40m) when the app was commanded to switch to the 20m FT8 frequency (14074). This matches the `debug.log` sequence from that attempt:
```
bandSelect: index=20, band=14074000, controlMode=1, rigConnected=true
setOperationBand: setting freq=14074000 (rig.getFreq=7074000)
```
...with no subsequent log line ever showing the cached frequency updating to 14074000 afterward, consistent with the radio's display never actually moving. So this isn't just "the log doesn't prove it worked" — it's a direct, repeatable observation that the command had no effect on the radio at least some of the time.

@laniol8926

Copy link
Copy Markdown
Author

Ruling out the hardware definitively: the same QMX and same USB cable work completely normally under WSJT-X (running directly on the machine that was hosting the phone's MTP connection during this testing) — CAT, frequency control, and audio all fine there. So the three issues reported above (TX audio silently falling back to the phone's own speaker after a USB reattach, the radio not always retuning on a band change, and the earlier intermittent full USB detaches) are software-side in FT8AF, not the radio or cable.

@laniol8926

Copy link
Copy Markdown
Author

One more hardware variable ruled out: all of the phone-side testing above was repeated across two different USB-OTG cables/adapters between the phone and the QMX, with the same symptoms both times. Combined with the WSJT-X result above, that's the radio, the Quadra-side cable, and now both OTG cables all cleared — this is squarely software in FT8AF.

@laniol8926

Copy link
Copy Markdown
Author

Last piece of hardware-elimination: installed a different native Android FT8 app (danleetw/FT8TW) on a different, older Samsung Galaxy phone against the same QMX, and it's getting real decodes with no issues. So this isn't specific to the Moto G 3rd Gen used in the testing above either — two different phones, two different apps, same radio: only FT8AF shows the symptoms reported in this thread. That's about as clean a "software, not hardware" signal as this setup can produce.

@laniol8926

Copy link
Copy Markdown
Author

Correction/update to everything above — new evidence changes the picture significantly, and I want to flag that clearly rather than let the "it's FT8AF software, not hardware" framing in my earlier comments stand as the final word.

Summary of a cleaner round of testing:

App Phone (Android ver.) Result
FT8AF (this PR) Samsung Galaxy (Android 13) TS-480 works, TX and RX both fine
FT8AF (this PR) Moto G 3rd Gen (Android 15) CAT connects; audio does not work
danleetw/FT8TW (unrelated native app) Samsung Galaxy (Android 13) Works, real decodes
danleetw/FT8TW (unrelated native app) Moto G 3rd Gen (Android 15) CAT connects; audio does not work

Two independently-written apps, same split, same phones, same radio. That's a strong signal this is an Android 15 platform-level change, not a bug in either app's code.

The specific clue: on Android 15, plugging in the QMX only shows the standard "which app do you want to open this with" chooser — no separate USB-device-access permission dialog appears, for either app. On Android 13, the explicit permission prompt does show. CAT/serial still works on Android 15 (likely covered by whatever implicit grant comes from the app-chooser/intent-filter match), but USB Audio Class access needs its own explicit UsbManager.requestPermission() round-trip, and that dialog apparently never fires on Android 15 for this composite (CDC-ACM + UAC) device — so audio access silently has no permission and fails, with no error surfaced to the user either way.

I don't have logcat access in my environment to confirm the exact permission-API-level mechanism, but given it reproduces identically in two unrelated codebases, I'd treat this as an Android-version compatibility issue to track (and maybe worth a GitHub issue of its own, separate from this PR) rather than something to chase inside FT8AF's own USB-audio code. Apologies for the earlier, more confident "it's definitely FT8AF" framing — should have held that more loosely given how early those comments were in the testing.

@laniol8926

Copy link
Copy Markdown
Author

One more refinement, and this is good news: tried a third app on the same Android 15 Moto G — QFT8, which was written specifically for the QMX — and it gets real decodes, no issue.

So this isn't an unconditional Android 15 platform block after all. It's narrower than that: whatever FT8AF and danleetw/FT8TW are both doing (or not doing) to request/claim the USB Audio Class interface doesn't work on Android 15, but QFT8's approach does. That means there's a working reference implementation to compare against — this is a fixable Android-15 compatibility bug in the USB-audio permission/claim flow, not something to just wait out. I don't have QFT8's source to compare against, but if anyone has access to it (or wants to sniff what permission calls it makes vs. what UsbAudioDevice/openUsbAudioInput do here), that'd be the fastest way to close this out. Still think it's worth its own tracking issue separate from this PR, now with "QFT8 works, so there's a known-working pattern" as a starting point.

@laniol8926

Copy link
Copy Markdown
Author

Closing summary of the real-hardware testing on this PR, pulling the thread above together:

  • This PR's actual change (QMX via hamlib) is validated. CAT control, frequency set/read, and PTT all work correctly through the hamlib QMX backend on Android 13.
  • The audio-related symptoms reported above (TX silently routing to the phone speaker, no waterfall/decodes, "rig keyed but silent") are not caused by this PR, and not a radio/cable/OTG-adapter problem — ruled out via WSJT-X on the same radio/cable, two different OTG cables, and a second phone.
  • The real cause is an Android-OS-version compatibility gap, not a platform dead-end. Confirmed Android 13 works and Android 15 doesn't for USB audio access, reproducing identically in both FT8AF and the unrelated danleetw/FT8TW app — same radio, same phones. (Android 14 wasn't tested, so "broken on anything newer than 13" is an extrapolation, not confirmed directly.) But a third app, QFT8 (written specifically for the QMX), gets clean decodes on the very same Android 15 phone — so whatever changed in newer Android isn't an unconditional block, it's that FT8AF's (and FT8TW's) current approach to claiming the USB Audio Class interface doesn't survive it, while QFT8's does.

Conclusion: this isn't something to wait out — it's a known-fixable gap. FT8AF (and FT8TW, separately) would benefit from updating USB-audio permission/claim handling to work on current Android versions, ideally informed by comparing against how QFT8 does it. Worth its own tracking issue separate from this PR, since it's pre-existing and affects every USB-audio rig, not just the QMX. Happy to help narrow it down further if useful — thanks for bearing with the long comment thread while this got untangled.

@patrickrb

Copy link
Copy Markdown
Owner

I love this, thank you!

@laniol8926

Copy link
Copy Markdown
Author

Follow-up: two concrete leads on the issues above (code-level, not just symptoms)

Dug into UsbAudioDevice.java to find root causes for the two bugs reported earlier (TX silent, audio falls back to phone mic/speaker after reattach on Android 15). Two separate, independent findings:

1. TX audio format isn't actually enforced — likely explains weak/no drive on the QMX

  • activateOutput(int sampleRate) (UsbAudioDevice.java ~L644-663) sets outputSampleRate unconditionally from the caller's request (FT8TransmitSignal.java:1298 passes 48000) — there's no negotiation/readback against the device's real USB Audio Class descriptors, unlike the input path (activateInput/selectInputAltSetting, ~L449-503, ~L563-639), which does parse descriptors and falls back through several strategies if the requested rate isn't supported.
  • Output channel count is auto-derived from endpoint packet size (channelsForMaxPacketSize, ~L659) and defaults to stereo (2), not mono.
  • TX channel routing defaults to AudioChannelSelect.BOTH (duplicate to both channels) — see GeneralVariables.java:141. Mono/left-only is only reachable via a manual per-operator setting in Settings > Radio & Audio > Audio (wave/AudioChannelSelect.java).

I can confirm this matters on real hardware: on the QMX, both WSJT-X and QFT8 (a third-party app with adjustable audio routing similar to WSJT-X) require switching explicitly to mono/left-channel to get adequate TX drive — duplicating to both channels is not just non-ideal, it's insufficient to fully drive the transmitter. That's consistent across two independent, unrelated codebases, so it looks like a real characteristic of the QMX's USB audio input stage (e.g. only the left channel is actually summed into the modulator), not an app-specific quirk.

Given that, defaulting txAudioChannel to BOTH works against QMX users specifically — they'll see weak/no drive until they discover the LEFT setting buried in Settings. Might be worth either defaulting to LEFT when the selected rig is the QMX (Hamlib) entry, or at least calling it out in whatever setup guidance exists for this rig.

2. Likely root cause of the Android-15-only audio reconnect failure

ComposeMainActivity.kt's reattach handling uses fixed, un-verified delays before re-initializing audio: registerUsbDetachReceiver()/onNewIntent() re-request USB permission on USB_DEVICE_ATTACHED (correctly — there's a comment there noting permission can drop across unplug/replug), then call reinitializeAudioInput() after a flat 2000ms sleep (one path) or 3000ms (another, in initData()). Each activateInput/activateOutput call does its own claimInterface(..., true) with no retry — a failure is just logged, not retried or surfaced, and the pipeline silently falls back to the phone's built-in mic/speaker.

No code anywhere distinguishes Android 13 vs 15. A fixed delay tuned against 13's re-enumeration timing could simply be too short on 15's USB host stack, especially for a composite CDC-ACM+UAC device like the QMX — which would exactly match what we saw: CAT/serial reliably reconnects (its own retry path works), but USB audio claim loses the race and silently falls back with no error surfaced.

If this is reproducible on other Android 15 devices, a retry-with-backoff on claimInterface (instead of a single fixed-delay attempt) on the audio reclaim path seems like the most direct fix to try first.

@laniol8926

Copy link
Copy Markdown
Author

Follow-up: CAT and audio use genuinely different USB mechanisms — this likely explains why one survives Android 15 reattach and the other doesn't

Looked into whether this needs an Android-specific build of libusb or hamlib. Short answer: no — both are already correctly built for Android. But the two paths are architecturally very different, and that difference lines up with the symptom.

CAT (hamlib) never touches libusb at all. It's configured --without-libusb in build_android.sh. Since hamlib can't open a raw tty on Android, the JNI layer (hamlib_jni.cpp) works around this with a loopback TCP socket: hamlib is told its "rig_pathname" is 127.0.0.1:<port>, and a pump thread shuttles bytes between that socket and whatever Java-side USB-serial driver is actually talking to the radio. Native hamlib code is fully insulated from Android's USB host API and from libusb — there's nothing here that can fail the way the audio side can.

Audio does use libusb, the standard unrooted-Android way: UsbAudioDevice.java calls Android's own connection.claimInterface(...) + connection.getFileDescriptor(), then hands that fd to native code, which calls libusb_wrap_sys_device() against it. Worth noting: a fresh libusb context is created on every single nativeWrite call, not once per session, and there's still no retry if the claim/wrap fails (same single-attempt pattern flagged in the earlier comment).

Checked device_filter.xml too, in case this was a VID/PID registration gap — it isn't. The QMX's actual CDC/serial VID:PID (0483:a34c, confirmed from the real hardware log: serial.prepare: matched 0483:a34c) is already listed under "ST CDC." So CAT's reliability on Android 15 isn't because it has broader permissions registered than audio does — it's because its path has no dependency on Android's native interface-claim/kernel-driver-detach machinery succeeding at all.

Likely sharper reason audio specifically breaks: UsbAudioDevice.detachKernelAudioDriver() has to force-claim the AudioControl interface to detach Android's own built-in snd-usb-audio kernel/ALSA driver before the app can get raw access to the USB Audio Class endpoints (already documented in AGENTS.md's "FT8 TX audio pipeline" section, item 4). That's a meaningfully more privileged operation than just opening a serial port — it's actively taking an interface away from a kernel driver that's already bound to it. If Android 15 tightened SELinux/USB permission policy around forced kernel-driver detachment for unprivileged apps (plausible, unconfirmed), that would explain exactly this symptom: CAT (no competing kernel driver, no detach needed) keeps working, audio (actively fighting the kernel's own ALSA driver for the interface) starts losing that fight.

On "QFT8 sees it as a valid radio, the others don't" — one speculative but testable explanation: Android has a native way to handle USB Audio Class devices that doesn't require any of this — treating the QMX as a normal system audio route via AudioManager/AudioDeviceInfo (TYPE_USB_DEVICE/TYPE_USB_HEADSET) and AudioRecord/AudioTrack.setPreferredDevice(), letting the existing snd-usb-audio kernel driver and ALSA handle everything, instead of fighting it for raw access via libusb. If QFT8 uses that approach, it would never need to force-detach anything, and would sidestep this entire failure class by design — which would also explain why it keeps working on Android 15 without doing anything unusual at the permission layer. Can't confirm this without QFT8's source (its author has declined to share it, for now — see github.com/lquesada/qFT8/issues/32), but it's checkable independently: does the QMX show up as a selectable device in Android's own Sound settings / AudioManager.getDevices() when plugged in, regardless of which app is running? If so, that's a real alternative worth prototyping against — likely more robust long-term than chasing retry/backoff tuning around the current libusb-based claim.

@laniol8926

Copy link
Copy Markdown
Author

Update: narrowed this down further with more hardware testing — correcting/refining the earlier framing

More testing on the Moto G (Android 15) ruled out two things I'd floated as possible fixes, and landed on a cleaner explanation.

Ruled out — rig profile isn't the variable. Tested both "Qrp Labs QMX (Hamlib)" (this PR's new entry) and the existing "Qrp Labs QDX" (Kenwood TS-590 emulation) against the same QMX on the same phone. CAT works fine either way (with QDX needing a manual mode switch from the default USB/SSB to Digital, as expected). Audio fails identically with both — this isn't a Hamlib-backend-specific problem, it's purely in the audio layer, independent of which CAT backend is driving the radio.

Ruled out — it's not about which in-app audio-device option is selected. Tried all three: "Default," the AudioManager-routed "USB-Audio — QMX Transceiver (USB audio)," and the raw-libusb "QMX Transceiver (USB Direct)." All three fail on this phone — no waterfall/decodes on RX in any of them; on USB Direct specifically, TX keys the rig but drive is insufficient regardless of trying LEFT or RIGHT channel individually.

The actual answer, confirmed independent of any app: with the QMX plugged into this Moto G and nothing running, Android's own Settings > Sound shows zero device options for it. The kernel/ALSA layer never registers this composite device as a sound card on this Android 15 build at all. That's the real reason "Default" and the AudioManager-routed "USB audio" option can't work here — there's no AudioDeviceInfo for either of them to use, because the OS genuinely never creates one for this device on this phone. That's a fixed characteristic of this device+Android-build combination, not something fixable by changing FT8AF's default audio-device selection.

That leaves "USB Direct" (raw libusb, bypassing the OS's sound-card registration entirely) as the only path that can possibly work on this phone — and QFT8 getting real decodes on this same phone/radio combination confirms that path is achievable, since QFT8 can't be relying on OS-level sound-card registration either if the OS never creates one. So FT8AF's "USB Direct" implementation has a real, fixable gap (not a platform dead end): RX is getting nothing at all, and TX keys but is underdriven on either single channel.

Net effect: walking back the "Android 15 permission-timing regression" framing from earlier in this thread in favor of this — the fix has to happen somewhere in UsbAudioDevice's raw-libusb RX/TX path specifically (altsetting/endpoint negotiation, sample format, or the channel-routing default already flagged earlier), since that's the only mechanism that can work here at all, and it's demonstrably achievable on this exact hardware by at least one other app.

@laniol8926

Copy link
Copy Markdown
Author

Pulled the phone's debug.log directly — found the exact, 100%-reproducible TX failure point

Pulled /sdcard/Android/data/radio.ks3ckc.ft8af/files/debug.log off the Moto G (Android 15) after a ~24-minute test session and found something more specific than anything reported so far.

TX fails identically every single time, with no variation — three separate TX attempts logged (18:18:21, 18:18:51, 18:29:50), each one exactly the same:

playViaUsbAudio: start, VID=0483 PID=A34C samples=151680 rate=12000
UsbAudioDevice: kernel audio driver detach via AudioControl iface 2: OK
playViaUsbAudio: device opened, output activated at 48000 Hz
playViaUsbAudio: calling writeAudio playLength=151680 rate=12000
UsbAudioDevice: trying libusb native write fd=<n> iface=4 alt=1 ep=0x03 maxPkt=300 bytes=2426880 outputRate=48000 ch=2
UsbAudioDevice: libusb native write FAILED (rc=-1 IO), trying UsbRequest fallback
playViaUsbAudio: writeAudio returned FAILED — TX DROPPED, no audio sent this cycle (rig keyed but silent)

Kernel driver detach succeeds every time, the device opens and activates cleanly at 48kHz every time — and then the very first native write fails immediately, on a device that was just successfully opened seconds earlier. This isn't a race after a reattach event; it fails on fresh state every time. That means a retry/backoff around the write (my earlier suggestion) wouldn't help — it would just hit the same wall again.

Found a likely reason in the native code. nativeWrite() in usb_audio_capture.cpp receives interfaceNumber and altSetting as JNI parameters (and the log confirms they're correctly passed in — iface=4 alt=1), but both are explicitly commented out as unused in the function signature:

Java_com_k1af_ft8af_wave_UsbAudioNative_nativeWrite(
        JNIEnv* env,
        jclass /*clazz*/,
        jint fd,
        jint /*interfaceNumber*/,
        jint /*altSetting*/,
        jint endpointAddress,
        ...

The function wraps the already-open fd (libusb_wrap_sys_device) and immediately submits an isochronous transfer to the endpoint — it never itself calls libusb_claim_interface() or libusb_set_interface_alt_setting(). It's entirely trusting that whatever the Java side (UsbAudioDevice.activateOutput()) configured on that fd earlier is still in effect by the time this runs. An isochronous URB submitted when the kernel doesn't currently have that exact alt-setting active on that interface fails with a generic I/O error — which would reproduce identically every time, matching the log exactly.

Might be worth having the native side explicitly re-assert the alt-setting (libusb_set_interface_alt_setting(handle, interfaceNumber, altSetting)) right before the first libusb_submit_transfer in nativeWrite, rather than relying on Java-side state alone — especially since libusb_wrap_sys_device gives libusb no visibility into whatever claim/alt-setting bookkeeping the Java UsbDeviceConnection already did on that same fd.

One correction to earlier comments in this thread: in this same log, RX audio input never actually switched to the QMX at all — every MicRecorder line shows audioInputDeviceId=0 ("system default mic") for the whole session. So the real-looking FT8 decodes seen earlier in testing very likely came from the phone's built-in mic picking up the QMX's own speaker acoustically, not from genuine USB audio RX. Worth treating "RX decodes appeared" with suspicion in any test where the mic source wasn't explicitly confirmed as the USB device — it's a plausible false positive.

@laniol8926

Copy link
Copy Markdown
Author

Correction: the test phone's model was misidentified in earlier comments

Several comments above (the initial hardware-elimination round) identify the Android 15 test phone as a "Moto G 3rd Gen (XT1541)." That's wrong, and worth fixing on the record since it's an easily-checkable claim: a 2015-era Moto G 3rd Gen couldn't run Android 15 at all.

Just connected the same phone to a Windows laptop with real adb and checked properly — adb devices reports the model as motorola_edge__2022_. It's a Motorola Edge (2022), not a Moto G 3rd Gen. The earlier identification came from a USB descriptor string (lsusb on the Linux board this project's been developed on) that was apparently stale or generic — not an actual reflection of the hardware.

Doesn't change anything about the technical findings in this thread (CAT, TX audio, the libusb I/O error, the Android-13-vs-15 behavior) — just correcting the device name in case anyone goes looking it up.

@laniol8926

Copy link
Copy Markdown
Author

Real adb logcat access now available (Windows laptop, genuine x86_64 toolchain) — found a concrete bug in the Hamlib socket bridge itself

Got real adb/adb logcat working for the first time in this whole investigation (via a Windows machine with Android Studio — the Linux ARM board this project's been developed/tested on can't run Google's own adb at all, x86_64-only). That gives direct system-level logs instead of just the app's own filtered debug.log, and it surfaced something more fundamental than the audio bugs reported so far.

The Hamlib CAT backend's own loopback-socket bridge isn't actually relaying commands through to the real serial port.

With "Qrp Labs QMX (Hamlib)" selected, hamlib's native code wrote the Kenwood FA; command (get/set frequency) to its internal loopback socket — confirmed via hamlib's own debug trace (network_flush, network_close) — and got 0 bytes back after a 2-second timeout, three times in a row.

Meanwhile, the real USB serial connection to the QMX (CableSerialPort's own serial.send log) only ever shows ID; being written — never FA;, and no read from the port appears in the log at all during this test.

So the bridge (hamlib_jni.cpp's loopback TCP socket + pump thread that's supposed to shuttle bytes between hamlib and the real Java USB-serial layer) appears to only forward some initial handshake command (ID;) through to the actual port — hamlib's real ongoing traffic (FA; and presumably everything after) just dead-ends in the socket, with hamlib timing out waiting for a reply that can never arrive.

This likely explains an earlier false-positive in this same PR thread. The very first hardware test reported CAT as "confirmed working" because the app's displayed frequency matched what was set (setOperationBand: setting freq=7074000 (rig.getFreq=7074000)) — but if the bridge only relays an initial ID; and nothing else, that match could just be the app echoing back its own in-memory state rather than a genuine round-trip through the hardware. Worth treating that earlier "CAT works" result with some suspicion until it's re-confirmed with the bridge fixed.

Separately, unrelated to Hamlib: in a different test run (wrong rig profile selected, ICOM CI-V — not this PR's entry), logcat showed MicRecorder: startRecord: startRecording() called on an uninitialized AudioRecord followed by Recording failed, state code: 1 — Android's own framework warning for calling startRecording() on an AudioRecord that never finished construction. Worth checking separately whether MicRecorder verifies AudioRecord.getState() before calling startRecording().

Happy to dig into hamlib_jni.cpp's pump-thread logic directly if useful — now that real adb logcat is available, further testing should be much faster to instrument.

laniol8926 and others added 8 commits October 21, 2026 02:36
Real-hardware testing on PR patrickrb#925 found that FA; (frequency) commands
from the Hamlib QMX backend never reach the radio after the initial
rig_open() handshake, despite hamlib logging the write and timing out
waiting for an ack. The sink callback that forwards hamlib's CAT bytes
to the real serial connector silently returns when getConnector() is
null, with no log anywhere -- making this exact failure mode
undiagnosable from the app's own logs. This adds a temporary warning
log so the next real-hardware test can confirm or rule out this path
directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
Ruled out the null-connector theory from the previous diagnostic commit:
hamlib's ID; probe does reach the real serial port, but FA; (sent moments
later in the same rig_open() call) never does, despite hamlib's own
write_block() logging a successful write in both cases. CableSerialPort
had zero logging on the receive path, so "the radio never replies" was
never actually verified either.

This adds:
- CableSerialPort.onNewData: logs every byte actually read off the wire
  (serial.recv[...]), before any rig-specific listener sees it.
- hamlib_jni.cpp's pump thread: logs exactly what it reads from hamlib's
  loopback socket on every read(), to confirm whether FA; ever reaches
  the pump thread at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
…hange

Real-hardware testing on PR patrickrb#925 found the QMX keeps falling back from
Digital to USB mode under the Hamlib backend, even though
setUsbModeToRig() correctly asks hamlib for PKTUSB. hamlib's QMX
backend (qrplabs_qmx_caps) uses the generic kenwood_set_mode(), which
only sends MD6; -- it has no knowledge of Q41;, the QRP Labs-specific
follow-up this exact radio needs to actually commit to and stay in
Digital mode. This is the same gap Dan Lee's native app previously had
to hand-patch for the same radio (see project history).

Testing also showed the mode reverts after any frequency change alone
(FA...;), not just at mode-set time, so Q41; is re-sent after
setFreqToRig() too, scoped to the QMX's model ID only so it never
reaches any other Hamlib-backed rig (currently just the FT-891 entry).

Not unit-testable: this path is gated behind ensureOpen(), which
HamlibRigTest's own doc comment already notes always returns false in
a JVM unit test (no native lib) -- same as every other hamlib call in
this class. Needs real-hardware re-verification, same as the rest of
this PR's hamlib-backed behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
Real-hardware testing of the previous Q41; fix found the QMX still
reverts from Digital to USB after a frequency change, despite
setFreqToRig() now re-sending Q41;. rig_get_freq() sends the same FA;
command as a query (no parameter) -- if that alone also triggers the
reversion, and the app polls frequency periodically (CAT-liveness/dial
sync), mode would keep reverting on every poll regardless of the
set-path fix. Mirrors the same Q41; follow-up onto the read path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
… MD6;

The Q41; fix (e046e0a, 3789712) was based on a wrong premise. Checked
the official QMX CAT manual (qrp-labs.com/images/qmx/manuals/
cat_1_04_003.pdf): there is no Q41 command. The Q-series commands are
Q0-QB; Q4 is "Get/Set TX Rise Threshold" (0-99%, default 80), so
"Q41;" was actually setting TX Rise Threshold to 1% on every mode-set,
freq-set, and freq-read -- an unrelated, actively harmful side effect,
not a mode fix.

Root cause, verified against hamlib's vendored source: the QMX backend
(qrplabs_qmx_caps) sets no mode_table override, so kenwood_set_mode()
falls back to the generic kenwood_mode_table, where RIG_MODE_PKTUSB is
table index 13. kenwood_set_mode() encodes any index > 9 as a letter
('A' + index - 10), so PKTUSB becomes "MDD;" -- not a valid QMX mode
character. The manual only documents single-digit MD values (1, 2, 3,
5, 6, 7, 8, 9), so "MDD;" is silently rejected; setUsbModeToRig() was
never actually setting Digital mode via this path at all.

Table index 6 holds RIG_MODE_RTTY (commented "// FSK Mode" in hamlib's
own source) -- exactly the QMX manual's own mode 6, "FSK", its real
digital mode. hamlib's string table maps "RTTY" to RIG_MODE_RTTY
directly, so requesting that mode produces the valid "MD6;" through
hamlib's normal, standard code path -- no raw command bypass needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
Real-hardware testing keeps showing "hamlib model 9" open instead of
2057 when "Qrp Labs QMX (Hamlib)" is selected, even right after a
fresh install. Traced every writeConfig("civ", ...) call site and
found none of them apply to a Hamlib-backed rig selection -- the only
writers are the ICOM-gated repair path (repairCivAddressAgainstModel,
which explicitly returns early for any non-ICOM instructionSet) and
the manual hex-edit field. So the earlier civAddress/CivAddressConfig
byte-masking theory doesn't hold up on inspection and needs direct
verification rather than more static reading.

Logs the exact rigName/address applied in setAddrAndBauRate() (rig
selection) and the exact civAddress value used to construct HamlibRig
in connectRig(), to see empirically where 2057 becomes 9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
Root-caused via real hardware testing + diagnostic logging: selecting
"Qrp Labs QMX (Hamlib)" always opened hamlib model 9 instead of 2057
after any app restart. RadioAudioSettings.kt (the active Compose rig
picker) persists civAddress through CivAddressConfig.encode(), which
masks with "& 0xFF" since a real ICOM CI-V address is always a byte --
2057 (0x809) round-trips through that as 9. Confirmed on-device:
connectRig() logged "constructing HamlibRig(civAddress=9)", and hamlib
model 9 (SDR#) opened, sending its own unrelated handshake bytes that
put the QMX into its firmware debug terminal menu instead of CAT mode.

Fix: a new GeneralVariables.hamlibModelId field and "hamlibModel"
config key, written as a plain unmasked decimal string and loaded the
same way, used instead of civAddress to construct HamlibRig in
MainViewModel.connectRig(). civAddress and its existing byte-range
persistence/repair logic (issue patrickrb#753) are untouched -- that contract
is relied on elsewhere for genuine ICOM addresses and didn't need
widening. Updated both the active picker (RadioAudioSettings.kt) and
the legacy one (ConfigFragment.setAddrAndBauRate) to keep the new
field in sync; added hydration tests covering the fix and a malformed
value.

This also explains several earlier rounds of real-hardware testing on
this PR that looked like CAT/mode bugs in the Hamlib backend itself
(including the reverted Q41; mistake) -- they were actually testing
the wrong hamlib backend (SDR#) the whole time, not QMX at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
The previous fix (47c8fc4) left a real gap: an install that had
already selected a Hamlib rig before "hamlibModel" existed loads
hamlibModelId=0 on first launch after updating. Confirmed on real
hardware: HamlibNative.nativeOpen(0, ...) -> hamlib's rig_init(0)
fails, and ~0.5s later the whole process aborts with a native SIGABRT
("pthread_mutex_lock called on a destroyed mutex") -- not a graceful
failure, a hard crash loop on every launch.

Two fixes:
1. HamlibRig.ensureOpen() now refuses to call nativeOpen at all when
   modelId <= 0 -- a bad model ID can never reach native code again,
   regardless of how it got that way.
2. connectRig() self-heals a <= 0 hamlibModelId from the currently
   selected rig's own rigaddress.txt entry (by modelNo) and persists
   it, so an existing install recovers on its next connect instead of
   needing the user to manually re-select the rig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
Trims the audioFormat field comment to the why in a few lines (per Copilot
review feedback on PR patrickrb#925, where this fix originated, carried over here
since it's the same underlying change).

Extracts reinitialize()'s restart check into
MicRecorder.shouldRestartAfterReinitialize(boolean), matching this file's
existing convention for pinning a load-bearing boolean decision with its
own test (captureLoopShouldContinue, shouldJoinCaptureThread), and adds
MicRecorderRestartDecisionTest covering both scenarios: restart after a
transient failure left isRunning false without stopRecord(), and no
restart after stopRecord() itself cleared shouldBeRunning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
@laniol8926

Copy link
Copy Markdown
Author

Addressed findings 6-10 (head a99d596f):

CI fix + findings 2-5 from your previous review are at 4f44820e, confirmed on real hardware (Windows build, JBR 17 + NDK): all 4 test classes pass, assembleDebug succeeds including the native glue, and a device test confirmed modelIdFor resolving 2057 correctly with the old hamlibModel key gone, Q90/MD6 both sent, 16-bit capture, real decodes.

laniol8926 and others added 10 commits October 22, 2026 20:31
persisted AudioDeviceInfo id goes stale

Confirmed on real hardware: power-cycling a QMX alone re-enumerated its USB
audio interface with a new AudioDeviceInfo id, reverting the Settings
input-device picker (and the actual MicRecorder/FT8TransmitSignal routing)
to "Default" even though the same physical device was still attached --
AudioDeviceInfo's numeric id is not guaranteed stable across a USB
reconnect, but its product name is, and the app already persists the
device's VID:PID whenever it's picked (just never used it as a fallback).

Added UsbAudioDevice.resolveDeviceIndex: exact id match first, else match
by the product name of the live USB device whose VID:PID equals what was
persisted. Wired into MicRecorder.findAudioDeviceById (input),
FT8TransmitSignal.findAudioDeviceById (output), and
AudioDeviceSpinnerAdapter.getPositionByDeviceId (the Settings picker's own
re-selection on screen open) -- all three previously only matched by exact
id, so a power-cycle silently fell back to Default in all of them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
persisted for framework picks, and exact name match fails on the HAL's
decorated name

Blocker 1: picking a framework AudioManager USB device (e.g. "USB-Audio -
QMX Transceiver") always persisted VID:PID 0:0 -- getUsbAudioDeviceInfo()
only returns non-null for the separate USB-direct list, so the picker's
`else if (deviceId != -1)` branch unconditionally zeroed it, leaving
resolveDeviceIndex's fallback with nothing to fall back to. Fixed by
looking up the raw UsbDevice by product-name match
(AudioDeviceSpinnerAdapter.getUsbFrameworkDeviceProductName +
UsbAudioDevice.matchByProductName) and persisting ITS VID:PID instead, in
both RadioAudioSettings.kt (Compose) and ConfigFragment.java (legacy).

Blocker 2: even with VID:PID persisted correctly, UsbDevice.getProductName()
("QMX Transceiver", confirmed via dumpsys usb) never equals the
AudioDeviceInfo list's name for the same card ("USB-Audio - QMX
Transceiver", confirmed via dumpsys audio) -- Android's generic USB-Audio
HAL decorates the raw descriptor name. resolveDeviceIndex's match (and the
new matchByProductName) now use case-insensitive containment instead of
equality.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
Both ImportSharedLogs (VIEW-intent import) and LogFileImport (web-logger
HTTP upload) capped a single import at 50 MB purely as a defensive OOM
guard, not a real technical limit -- an 87 MB real-world lifetime ADIF log
(an active contester's) was rejected by it. Raised both to 200 MB,
comfortably covering that case with headroom, while still bounding the
worst case for a hostile/pathological upload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
…itself

Real on-phone testing (Motorola Edge 2022, ~256MB app heap) found that
readFully()'s ByteArrayOutputStream-doubling growth means REJECTING a
stream over the 200 MB cap already needs close to 200-256MB of heap --
LogFileImportTest.readFully_throwsWhenExceedingCap got OutOfMemoryError
instead of the expected IOException. 100 MB keeps the rejection path's own
peak memory safely under that, while still covering the 87 MB real log
that prompted the original 50 MB -> 200 MB change.

This is a stopgap, not a confirmed fix: actually parsing an accepted
import this large still needs several times the raw file size under the
current whole-file-in-memory approach (buffer, decoded string, per-record
and per-field split copies, and the full parsed-record list all alive at
once), so whether a 100 MB accepted import itself succeeds on a 256MB-heap
device is not yet confirmed. A streaming rewrite is the real fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
whole file

Real on-phone testing found that even a modest size cap (100-200MB) on the
previous whole-file-then-split approach could make either the rejection
path itself OOM, or leave an accepted import's actual multi-copy parse
(buffer, decoded string, per-record/per-field split copies, the full
parsed-record list, all alive at once) unconfirmed to fit a 256MB-class
heap -- an 87MB real ADIF log is exactly the case that prompted this.

Adds AdifStreamReader: reads the source in small chunks via an
InputStreamReader (so a multi-byte UTF-8 character split across a chunk
boundary still decodes correctly), and yields one raw record at a time by
scanning only the unconsumed tail for the next <eoh>/<eor> tag
(case-insensitive). Peak memory is bounded by roughly one record's worth of
text, not the whole file, so there is no longer any need for a size cap on
this path at all.

ImportSharedLogs.doImport now takes a StreamOpener (re-opens the source,
e.g. a content:// URI, rather than a single already-open InputStream) and
streams it twice: once to count records for the existing progress-total
contract, once to parse and insert each record as it's read, instead of
collecting a full ArrayList<HashMap> up front. parseLogRecords' per-record
field parsing is extracted unchanged into parseSingleRecord, shared by both
the whole-body (still used by existing tests) and streaming paths, so
behavior is identical either way. Updated the three callers (MainActivity,
ComposeMainActivity, LogbookScreen) to pass a reopening lambda instead of a
single InputStream.

Scope: this covers ImportSharedLogs (the VIEW-intent/share import path,
what the original report hit). LogFileImport (the built-in web-logger's
HTTP upload path) keeps its whole-file approach and 100MB safety cap for
now as a separate follow-up, rather than doubling this change's size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
Real-hardware proof of the VID:PID/product-name fallback (a QMX
power-cycle changed its AudioDeviceInfo id from 1328 to 1354) showed that
these diagnostic logs only printed the REQUESTED id, which made a
successful fallback match look identical to an exact hit in the log --
deviceFound=true either way, with no way to tell which happened without
separately checking dumpsys. Logs the AudioDeviceInfo's own resolved id
alongside it in MicRecorder's reinit log and both of FT8TransmitSignal's
preferredDevice logs (normal TX and TUNE).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
multiByteUtf8CharacterSplitAcrossChunkBoundary_decodesCorrectly declared
<comment:4> for "Café", but ADIF's LEN counts UTF-8 bytes (AdifFormat's own
javadoc), and 'é' is 2 bytes -- so the correct declared length is 5. The
parser (extractFieldValue -> AdifFormat.sliceByUtf8Length) was already
correct and matches the app's own exporter; only the test fixture's
declared length (and its comment, which wrongly implied char-length
semantics) was wrong.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
@laniol8926

Copy link
Copy Markdown
Author

Real-world finding worth flagging for finding 8 (rigaddress.txt append-only ordering): while testing PR #950 (ADIF import) separately, the user's phone had this PR's build installed, got swapped to a build based on plain `dev` (no QMX entry) to test the import fix, then back again. In between, with the saved rig selection still pointing at the QMX's list index, the `dev`-based build resolved that same index to a different rig ("Yaesu FT-891 (Hamlib)", one line earlier in `dev`'s shorter `rigaddress.txt`) and sent its CAT commands to the actually-attached QMX — 64x literal `f` + newline, with zero replies. That apparently put the QMX into its serial setup-terminal mode, cleared by a power-cycle.

No data was corrupted and nothing crashed, but it's a concrete demonstration of the risk you flagged in finding 8: moving between app versions with different `rigaddress.txt` lengths, with a HAMLIB rig already selected, can silently resolve to the wrong backend and send garbage CAT to whatever's attached — not just a reordering/merge-conflict risk. Not something I'm proposing to fix in this PR (the append-only convention already does what it can at the file level), just flagging the sharper edge case for awareness, especially since QMX's vendor commands have shown they can put the radio into unexpected states.

laniol8926 and others added 5 commits October 22, 2026 21:45
Real-world testing of an 87 MB ADIF import (PR patrickrb#950) found two related
bugs, both surfaced by the same 73 malformed RST_SENT/RST_RCVD values
("59+30", "R-07", "-10db", "-20 dB", "--5", ...) that other loggers write:

1. QSLRecord's RST_RCVD/RST_SENT parsing used a strict Integer.parseInt,
   so any of those shapes threw, silently dropped the report (falling
   back to the "no report" sentinel), and dumped a full stack trace to
   logcat for every one (73 traces in this case). Added
   AdifFormat.parseReportLeniently: recovers the leading signed integer
   from each of the real-world shapes above, falling back to flagging
   invalid (with a one-line log, no stack trace) only when no digits can
   be found at all.

2. Even when isInvalid *was* set (by this or the existing FREQ parsing
   path), DatabaseOpr.doInsertQSLData never looked at it -- it hard-coded
   isInvalid=false on every successful insert/update, only ever reporting
   true for the unrelated missing-callsign case. So the import UI told
   the user "no invalid records" while 73 QSOs had silently lost their
   RST. Now passes record.isInvalid through to both doAfterInsert call
   sites (new insert and update-existing).

The record is still inserted either way (the one bad field already fell
back to a sentinel) -- this only fixes what gets reported, plus the RST
recovery itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
still count as added/updated

Real-world code review on this same PR found that passing record.isInvalid
as the isInvalid parameter made both AfterInsertQSLData consumers
(AdifImportTally's exclusive `when`, LogHttpServer's `if (isInvalid) {
...; return; }`) exclude a record that WAS written (just with one field
dropped, e.g. an unparseable RST) from "added"/"updated" entirely --
exactly the same under-reporting bug this PR set out to fix, just moved:
now the summary said "73 invalid" implying those 73 were never imported,
when they actually were.

AfterInsertQSLData gets a third parameter, hasWarning, independent of
isInvalid (never written at all, e.g. no callsign) and isNewQSL (new vs.
update): a record flagged by QSLRecord.isInvalid is now reported as
isInvalid=false (it WAS written), isNewQSL as normal, hasWarning=true.
Both consumers tally it alongside added/updated rather than instead of:
AdifImportTally gets a separate `warnings` count (surfaced in the import
dialog only when non-zero), and LogHttpServer/ImportTaskList get a
parallel warningCount/HTML row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
Real-hardware finding: with the QMX stuck in a non-CAT mode (not this PR's
own bug -- a separate build's stray serial writes left it there), HamlibRig
got a brand-new native bridge (loopback socket + pump thread) on every
single CAT call that ran while handle==0 -- 354 of them in about 17
minutes, via the app's ~3s CAT-liveness watchdog tick plus other CAT calls
queuing in between. Each cycle was already cleaned up correctly (no leak --
releaseHandle() still runs on every failure), but churning native
resources this often for an indefinitely unresponsive radio is wasteful.

Added a 15s backoff after a failed nativeCreate or nativeRigOpen: ensureOpen()
now skips the attempt entirely (returning false immediately) until the
backoff elapses, reset to 0 on the next successful open. The decision itself
(shouldAttemptOpen) is a pure function of (now, last-failure-time, backoff),
kept separate from ensureOpen() so it's testable without a real clock/sleep.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
@laniol8926

Copy link
Copy Markdown
Author

Real-hardware finding from unrelated testing today: with the QMX stuck unresponsive (a separate build's stray serial writes left it in a non-CAT mode, not this PR's bug), `HamlibRig` was creating a brand-new native bridge (loopback socket + pump thread) on every single CAT call that ran while the handle was unset -- 354 of them in ~17 minutes via the app's ~3s CAT-liveness watchdog. Each cycle was already being cleaned up correctly (confirmed: no leak, `releaseHandle()` runs on every failure), but churning native resources that often for an indefinitely unresponsive radio is wasteful.

Added a 15s backoff after a failed open (`ensureOpen()` just returns false immediately until it elapses, reset on the next successful open), pushed as `b30f7db6`. New tests for the backoff decision plus a regression test against the exact scenario (two CAT calls landing moments apart after a failure, only the first should touch the native layer).

laniol8926 and others added 9 commits October 23, 2026 00:55
… backoff

Code review on the backoff fix (b30f7db) flagged System.currentTimeMillis()
as the wrong clock: an NTP sync or manual time change could jump the wall
clock backward (stalling reopening far longer than 15s) or forward
(skipping the backoff entirely). elapsedRealtime() is monotonic and
unaffected by either. shouldAttemptOpen() itself is unchanged -- it's a
pure function of whatever clock it's given -- only ensureOpen()'s callers
and the field's own units change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
…, not

0, and inject the clock for deterministic tests

android.os.SystemClock.elapsedRealtime() returns 0 (unmocked default)
under a plain JVM unit test (app/build.gradle's
unitTests.returnDefaultValues), which collided with 0 as the "never
failed" sentinel: the very first backoff check after a failure saw
now == lastFailureAtMs == 0, took the "never failed" branch, and wrongly
allowed an immediate retry -- caught by
ensureOpen_repeatedCatCallsAfterFailure_doNotRetryWithinBackoff failing
under real test conditions (not Robolectric). On a real device
elapsedRealtime() is never exactly 0 in practice, so this was a test-only
symptom, but the sentinel collision was real regardless of clock source.

Moved the sentinel to Long.MIN_VALUE (never a legitimate elapsedRealtime()
reading) and added a third constructor overload injecting the clock as a
LongSupplier (defaulting to SystemClock::elapsedRealtime), so the backoff
window itself is deterministically testable rather than depending on the
JVM's unmocked default or real wall/monotonic time. Added a test for the
other half of the behavior -- retrying once the backoff genuinely elapses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
Both new backoff tests queued setFreqToRig() (which only submits to
HamlibRig's worker, it doesn't block for completion) and then immediately
moved the fake clock on the test thread -- a real race between the test
thread and the worker thread actually running that first open attempt.
In the retry test this was visible: if the clock jumped to the "past
backoff" value before the worker recorded lastOpenFailureAtMs at the
original time, the second call saw "now == lastFailure" (just-failed,
still within backoff) instead of the intended "well past it", and never
retried. The no-retry test's own assertion happened to hold regardless of
which way the race fell, so it passed, but just as accidentally.

Both tests now drain the worker (the existing submitForTest+latch pattern)
right after the first CAT call, before touching the clock, so the first
attempt's failure and its timestamp are guaranteed to have already
happened.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
Deferred since the very first night of this PR's work ("rig keys/
transmits, but audio is low"). Earlier investigation found a well-evidenced
cross-app cause: WSJT-X and QFT8 -- the two reference implementations
confirmed working on this exact QMX -- both require an explicit mono/
left-channel TX selection to get full drive; FT8AF's historical BOTH
default opens a mono AudioTrack and leaves the mono-to-stereo upmix to
Android/the USB Audio Class device, with this app having no control over
how much gain that upmix applies per channel, while an explicit LEFT
selection opens a genuine stereo track with the left channel at full scale
and the right silenced (AudioChannelSelect). Underdriven either way until
an operator happens to find the manual Settings > Radio & Audio > Audio
toggle.

Auto-defaults GeneralVariables.txAudioChannel to LEFT the first time the
QMX (Hamlib) model connects in MainViewModel.connectRig(), but only when
the operator has never touched that setting at all -- a new
DatabaseOpr.hasConfigKey() distinguishes "no row for this key yet" from
"a row exists holding the same value as the hardcoded default", so an
explicit choice (even BOTH itself) is never silently overridden. The
decision itself (shouldDefaultQmxTxChannelToLeft) is a pure, testable
function; HamlibRig.QRPLABS_QMX_MODEL_ID made public so MainViewModel can
reference the same constant HamlibRig itself already uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
volume-curve cut and enhancement effects

Real-hardware finding (deferred since the first night of the QMX PR's
work -- "rig keys/transmits, but audio is low"): a QRP Labs QMX still
showed low drive even with TX audio correctly on the left channel.
Logcat showed why: FT8AF's TX AudioTrack used USAGE_MEDIA/CONTENT_TYPE_MUSIC,
which resolves to AUDIO_STREAM_MUSIC -- and on this device the audio
policy manager applies a fixed -6 dB cut to that stream regardless of the
app's own volume index (confirmed happening at max index, 15/15), on top
of an OEM "Dolby Listener" enhancement effect inserted into the output's
mix chain.

Moves TX to USAGE_VOICE_COMMUNICATION/CONTENT_TYPE_SPEECH (the usage
"raw audio to a USB modem" apps commonly use instead of USAGE_MEDIA,
specifically to land on a different volume-source/output than media
audio), which escapes the -6 dB curve for certain and plausibly the Dolby
chain too if it causes routing to a different mixer thread. Also builds
the AudioTrack via Builder instead of the legacy constructor so
PERFORMANCE_MODE_LOW_LATENCY can be requested (a secondary, independent
attempt to dodge the enhancement chain -- NOT expected to touch the
volume curve itself, which applies regardless of mixer path) and so a
dedicated generated session id can be used instead of the literal 0 the
legacy constructor used, which implicitly joins the output's global
session-0 mix -- the exact session real hardware showed carrying the
Dolby effect. Logs the actual granted performance mode either way, since
a denied low-latency request otherwise fails completely silently.

Cross-rig: affects every rig's TX audio, not just the QMX -- its own PR,
same reasoning as the earlier RX-capture split.

Needs real-hardware confirmation before merge: does drive actually
improve, and does the existing Bluetooth-SCO output-routing workaround
(issue patrickrb#759, applyDefaultOutputRoutingOverride) still behave correctly --
it forces setPreferredDevice() regardless of usage so is not expected to
be affected, but hasn't been re-verified against a live SCO link.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
USAGE_VOICE_COMMUNICATION was confirmed via real dumpsys audio output
to resolve to AUDIO_STREAM_VOICE_CALL, sitting at its minimum volume
index (1 of 8) on the QMX's USB output -- far worse than the -6 dB
music-stream cut it was meant to escape, and with no easy way for the
operator to raise it outside an active call. Caught before an on-air
test confirmed it the hard way.

Keeps the dedicated session id + PERFORMANCE_MODE_LOW_LATENCY request
(aimed at the Dolby/session-0 effect specifically) but leaves the -6 dB
volume-curve cut itself unsolved, pending real per-device volume-curve
data for any future candidate usage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
…TION)

Pulls in 26cc47a, which reverts the USAGE_VOICE_COMMUNICATION change
merged earlier (a4929d0) after real-device testing confirmed it made
TX drive worse, not better.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
Root cause of the QMX "TX keys but drive is low/absent" symptom chased
over the last two sessions: it was never a volume/attenuation problem.
On this phone's USB-audio HAL, opening TX's AudioTrack while RX's
AudioRecord is still open on the same USB device fails at the ALSA
proxy (pcm_is_ready(): I/O error), and every subsequent TX write is
then silently dropped (write(), mPcm is NULL!) with no error surfaced
anywhere -- confirmed by instrumenting the actual sample values
written: full-scale all the way to AudioTrack.write(), yet nothing
reached the rig. A reference app (QFT8) that drives the same radio to
full output runs strictly half-duplex (close capture, play, reopen
capture), confirmed via its own logcat.

Fix: MainViewModel's existing beginKeying()/endKeying() choke point
(already used for PTT and Bluetooth SCO around every TX) now also
pauses and resumes RX capture via the new HamRecorder.pauseForTx()/
resumeAfterTx(), gated by the pure, tested
MainViewModel.shouldPauseRxCaptureForTx() -- only when RX capture and
TX output are confirmed to be the same USB device, so LAN rigs,
USB-direct paths, and non-shared-device setups are unaffected.

All the AudioAttributes/session-id/low-latency work from PR patrickrb#952 is
superseded by this -- that path never mattered, since the audio never
reached the rig in the first place.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SaooA8HLsiETQ12Tbwwyzf
@laniol8926

Copy link
Copy Markdown
Author

TX underdrive fully root-caused and fixed, confirmed on real hardware (2026-10-23).

This chased a false lead across several PRs/comments (AudioAttributes usage, volume curves, Dolby effects, channel layout) before the actual cause turned up: on this phone's USB-audio HAL, opening TX's AudioTrack while RX's AudioRecord is still open on the same USB device fails at the ALSA proxy level, and every subsequent TX write is then silently dropped — confirmed by instrumenting the actual sample values written (full-scale sine, peak 1.0, all the way to audioTrack.write()), yet nothing reached the rig. A reference app (QFT8) that drives the same radio to full output runs strictly half-duplex — closes RX capture, plays, reopens capture — and never hits the failure.

Fix: MainViewModel's existing beginKeying()/endKeying() choke point (already used for PTT and Bluetooth-SCO steering around every "over"/TUNE) now also pauses and resumes RX capture, via HamRecorder.pauseForTx()/resumeAfterTx(), gated by a pure/tested decision (MainViewModel.shouldPauseRxCaptureForTx()) that only fires when RX capture and TX output are confirmed to be the same USB VID:PID — a no-op for LAN rigs, USB-direct paths, and any setup where TX/RX aren't sharing one device.

Confirmed on real hardware: before the fix, every single TX logged alsa_device_proxy: proxy_open() pcm_is_ready() failed followed by ~2,530 write(), mPcm is NULL! lines per over, and the rig showed essentially no drive ("one bar"). After the fix (commit 27b31fd1), a clean logcat capture shows zero proxy_open fail / zero mPcm is NULL across a TUNE and a CQ, RX capture cleanly closing before TX and reopening ~24ms after, and — the clearest possible proof — a real station replying to the CQ (DECODE: ... replyToMe=true) immediately after.

Known follow-up, not blocking: shouldPauseRxCaptureForTx() requires a non-zero, matching VID:PID on both the input and output config. Anyone whose device was selected before VID:PID persistence existed (saved as 0:0) won't get the pause and will need to re-pick both devices in Settings, or this could be hardened to also match on resolved AudioDeviceInfo (card/product name) as a fallback — not done yet, flagging for a follow-up.

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