Skip to content

android: port AudioSwitchHandler improvements and update audioswitch - #474

Open
davidliu wants to merge 1 commit into
mainfrom
dl/update_audioswitch
Open

davidliu wants to merge 1 commit into
mainfrom
dl/update_audioswitch

Conversation

@davidliu

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: b7223ca

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 devin-ai-integration Bot 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.

Devin Review found 2 potential issues.

Devin Review

Comment on lines +206 to +212
HandlerThread currentThread = thread;
if (currentThread != null) {
currentThread.quitSafely();
}

public void setMicrophoneMute(boolean mute) {
audioManager.setMicrophoneMute(mute);
handler = null;
thread = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Old teardown disrupts reconnected audio

On a rapid stop-to-start transition, start() can activate a new switch before the old switch stops. The old teardown then changes shared Android audio state, disrupting the new session's routing.

Learn more

Each start creates a new HandlerThread after stop clears thread. The old switch's stop() is only queued on its old thread, so it can run after the new switch activates on a different thread. Both switches manage the same Android audio focus, mode, and routing; a late teardown can undo the new session's setup.

Example: A call stops, then reconnects immediately. The new thread activates the new switch while the old thread is busy. The old thread subsequently stops its switch and changes the device's audio mode or route, even though the new call is active.

Recommended fix: Serialize old-switch teardown and new-switch activation across session generations, for example by retaining one worker until teardown completes or queueing activation behind the previous teardown. Keep stop() safe when invoked from the worker thread, and test rapid stop/start with a delayed teardown.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 144 to +147
if (audioSwitch == null) {
handler.removeCallbacksAndMessages(null);
handler.postAtFrontOfQueue(() -> {
audioSwitch = new AudioSwitch(
context,
loggingEnabled,
audioFocusChangeListener,
preferredDeviceList
);
audioSwitch.setManageAudioFocus(manageAudioFocus);
audioSwitch.setFocusMode(focusMode);
audioSwitch.setAudioMode(audioMode);
audioSwitch.setAudioStreamType(audioStreamType);
audioSwitch.setAudioAttributeContentType(audioAttributeContentType);
audioSwitch.setAudioAttributeUsageType(audioAttributeUsageType);
audioSwitch.setForceHandleAudioRouting(forceHandleAudioRouting);
audioSwitch.start(audioDeviceChangeListener);
audioSwitch.activate();
final Handler startHandler = currentHandler;
startHandler.removeCallbacksAndMessages(null);
startHandler.postAtFrontOfQueue(() -> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Concurrent starts leave an active switch

If start() runs twice during switch construction, both start tasks can activate separate switches. audioSwitch retains only the second, so stopping the session leaves the first switch active.

Learn more

audioSwitch stays null while the first start runnable constructs the switch. A second start() can therefore schedule another runnable; removing queued messages does not cancel one already running. Both runnables pass the handler identity check and activate, but the second overwrites the only reference to the first.

Example: Call A enters new CommDeviceAudioSwitch(...). Another startAudioSession() arrives before A publishes. Call B queues another start. A and B each activate; a later stopAudioSession() stops only B, leaving A registered and active.

Recommended fix: Track a pending or in-progress start under the manager lock, and refuse duplicate start requests until that start is finished. Preserve generation checks so stop() can cancel an in-progress start, and test concurrent/repeated starts during slow construction.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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.

1 participant