Skip to content

android: port AudioSwitchHandler improvements and update audioswitch - #474

Open
davidliu wants to merge 7 commits into
mainfrom
dl/update_audioswitch
Open

davidliu wants to merge 7 commits into
mainfrom
dl/update_audioswitch

Conversation

@davidliu

@davidliu davidliu commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Brings the Android AudioSwitchManager in line with client-sdk-android's AudioSwitchHandler, and updates audioswitch to the same commit (039a35ae).

Changes

  • audioswitch bump + CommDeviceAudioSwitch: uses the communication device API on Android 12+ and AudioSwitch below that.
  • Background thread: AudioSwitch now runs on its own HandlerThread instead of the main looper.
  • stop() race fix: audioSwitch is cleared synchronously, so a quick stop→start creates a new switch instead of reusing a stopped one, which could leave audio stuck on the earpiece. Ported from fix(audio): clear audioSwitch synchronously in AudioSwitchHandler.stop() client-sdk-android#967.
  • Guard against stop() during switch creation: if stop() runs while the audio thread is still building the switch, the new switch is dropped before it starts. Previously it could end up activated and never stopped. client-sdk-android still has this race.
  • Settings apply after start: setting changes, including preferredDeviceList, now take effect on a running switch.
  • React reload: invalidate() now stops the audio session, so a reload releases audio focus and the handler thread.
  • Cleanup: removed the unused listener fields, enableSpeakerphone and setMicrophoneMute. Nothing in the SDK called them, and they changed routing directly on AudioManager, which would conflict with CommDeviceAudioSwitch.

No JS API changes.

Testing

  • Library compiles via example/android
  • Device test on Android 12+ (CommDeviceAudioSwitch): speaker/earpiece/Bluetooth/wired switching, selectAudioOutput
  • Device test on Android 11 or lower (AudioSwitch)
  • Quick disconnect→reconnect
  • Dev-menu reload during a call

🤖 Generated with Claude Code

Bring AudioSwitchManager in line with client-sdk-android's AudioSwitchHandler:

- Bump audioswitch to 039a35ae and use CommDeviceAudioSwitch on Android 12+.
- Run AudioSwitch on a dedicated HandlerThread instead of the main looper.
- Clear audioSwitch synchronously in stop() so a quick stop/start re-creates
  the switch instead of reusing a stopped one (client-sdk-android #967).
- Guard against stop() racing with switch creation on the audio thread,
  which could leave a switch activated and never stopped.
- Apply setting changes (including preferredDeviceList) to a running switch
  on the audio thread.
- Stop the audio session in the module's invalidate() so React reloads
  release audio focus and the handler thread.
- Drop unused listener fields, enableSpeakerphone and setMicrophoneMute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8d1bae7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@livekit/react-native Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

devin-ai-integration[bot]

This comment was marked as resolved.

@davidliu

Copy link
Copy Markdown
Contributor Author

@hiroshihorie can you take a look at this?

@hiroshihorie

hiroshihorie commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

@davidliu, my confidence with the Android space isn't high so I did an AI-investigation 🙂:

Update: I pushed fixes for 1 and 2, plus Robolectric tests in 0b775ca that force each race on SDK 30 and 33. Disabling either fix makes its test fail. Run them from example/android with ./gradlew :livekit_react-native:testDebugUnitTest. CI doesn't run them yet. 3 and the smaller things are still open.

The #967 fix and the guard for stop() during creation look right, and stopping the session in invalidate() is a nice addition. I also read through the audioswitch fork at 039a35ae, and I think the threading needs another pass before we merge.

1. Teardown and the next start run on different threads

✅ Addressed in 6e5211c. One HandlerThread now lives until release() (called from invalidate()), and creation and teardown are posted in order, so a teardown always runs before the next creation.

stop() quits the HandlerThread and the next start() creates a new one, so nothing orders the old switch's stop() against the new switch's activate(). Both change global AudioManager state:

  • If the old restoreAudioState() lands after the new activate(), it resets the audio mode and clears the communication device during the new session.
  • If the new cacheAudioState() runs before the old restore, the new switch saves MODE_IN_COMMUNICATION as the previous mode. It restores that when it stops, so the phone stays in communication mode after the call.

Quick stop/start is common in RN (StrictMode effects, screen remounts), so I don't think this is just theoretical.

I suspect this is also what's behind livekit/client-sdk-android#966. The log in #967 shows start() still seeing the old switch 456ms after stop(), which looks more like the teardown hadn't run yet than a visibility problem. If that's right, #967 changes which way the race goes, but a late teardown can still undo the new route and leave audio on the earpiece. I haven't reproduced this. It's from reading the log.

Suggestion: keep one HandlerThread for the lifetime of the module (quit it in invalidate()), and guard creation with a generation counter in place of handler identity. Then teardown and creation always run in order on one looper. start() shouldn't call removeCallbacksAndMessages on that shared handler, since that would drop a pending teardown.

2. A second start() leaks an active switch

✅ Addressed in 8a6c93c. I used a generation counter bumped by start() and stop() in place of the handler check, which also keeps the stop() during creation guard working with one long-lived thread.

Devin's comment is right here. audioSwitch stays null until the creation runnable publishes it, so a second start() queues a second creation. Both runnables pass the handler check, the second overwrites audioSwitch, and the first switch is never stopped. Checking audioSwitch != null inside the synchronized block before publishing fixes it. This was already there before the PR and is in client-sdk-android too, but it's a one-line fix.

3. audioswitch still calls back on the main thread

⏳ Still open. This needs the handler parameter in the fork.

The public constructors of AudioSwitch and CommDeviceAudioSwitch always build their scanner with Handler(Looper.getMainLooper()). So device callbacks run onDeviceConnected/onDeviceDisconnected, then selectAudioDevice and activate(), on main, while start/stop/selectDevice run on the worker. Before this PR everything ran on main, so this split is new for RN.

  • Android 7 to 11: registerAudioDeviceCallback delivers the initial device list later on main. activate() on the worker usually has no devices yet, so the first real route happens on main.
  • Android 12+: start() diffs the device list on the worker, and the registration callback diffs it again on main against the same unsynchronized communicationDevicesById. That happens on every start.

Since you own the fork, could we add a handler (or looper) parameter to the public constructors? Then RN and client-sdk-android can pass the worker handler, and everything really does run on one thread.

Smaller things

⏳ Still open, your call.

  • Routing on Android 12+ now goes through setCommunicationDevice. That's a big behavior change for a patch changeset. Maybe make it minor, or at least call it out in the changelog.
  • enableSpeakerphone, setMicrophoneMute and the public listener fields are removed from a public class (the module exposes it as audioManager). Probably nobody uses them, but it's technically an API removal.

client-sdk-android has 1 to 3 as well, so it might be worth fixing them there at the same time.

audioSwitch stays null until the creation runnable publishes it, so a
second start() during creation queued another creation. Both passed the
handler check, the second overwrote audioSwitch, and the first switch
stayed active with nothing left to stop it.

Replace the handler identity check with a generation counter bumped by
start() and stop(). A creation only publishes its switch if no other
start() or stop() ran after it was queued.
stop() quit the HandlerThread and the next start() created a new one, so
the old switch's teardown and the new switch's creation ran on two
threads in no particular order. If the old restoreAudioState() ran after
the new activate(), it reset the audio mode and cleared the communication
device during the new session. If the new switch cached the audio state
first, it saved MODE_IN_COMMUNICATION and restored it after the call.

Keep the thread until release() and post both teardown and creation in
order, so the previous session is always torn down first. The module's
invalidate() now calls release() to shut the thread down on reload.
Each test blocks the audio thread where a race happens and checks the
audio mode the switches leave behind, on SDK 30 (AudioSwitch) and SDK 33
(CommDeviceAudioSwitch):

- a quick stop then start ends with an active session
- stop() during switch creation drops the new switch
- a second start() during creation leaves nothing active after stop()
- a late teardown does not undo the next session
- release() stops the session and the audio thread

Run with ./gradlew :livekit_react-native:testDebugUnitTest from
example/android. Tests are excluded from the npm package.
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