Repository navigation
Conversation
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 detectedLatest commit: 8d1bae7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
|
@hiroshihorie can you take a look at this? |
|
@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 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
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:
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
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 3. audioswitch still calls back on the main thread
The public constructors of AudioSwitch and CommDeviceAudioSwitch always build their scanner with
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
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.
Brings the Android
AudioSwitchManagerin line with client-sdk-android'sAudioSwitchHandler, and updates audioswitch to the same commit (039a35ae).Changes
CommDeviceAudioSwitch: uses the communication device API on Android 12+ andAudioSwitchbelow that.HandlerThreadinstead of the main looper.stop()race fix:audioSwitchis 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.stop()during switch creation: ifstop()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.preferredDeviceList, now take effect on a running switch.invalidate()now stops the audio session, so a reload releases audio focus and the handler thread.enableSpeakerphoneandsetMicrophoneMute. Nothing in the SDK called them, and they changed routing directly onAudioManager, which would conflict withCommDeviceAudioSwitch.No JS API changes.
Testing
example/androidCommDeviceAudioSwitch): speaker/earpiece/Bluetooth/wired switching,selectAudioOutputAudioSwitch)🤖 Generated with Claude Code