From d0cee0d2cb76fd18278739607897fc912b02ba35 Mon Sep 17 00:00:00 2001 From: YashJainSC Date: Tue, 16 Jun 2026 17:01:36 +0530 Subject: [PATCH] fix(audio): clear audioSwitch synchronously in AudioSwitchHandler.stop() Audio output could get stuck on the earpiece (very low volume) after a disconnect() + connect() on a reused Room on Android 12+ (CommDeviceAudioSwitch). stop() nulled `audioSwitch` inside the posted teardown runnable while tearing down handler/thread synchronously. Because the field was not volatile and the write happened off the lock on the handler thread, a fast subsequent start() could read a stale (already-stopped) switch and skip re-creation via the `if (audioSwitch == null)` guard. With no new switch created, activate() never re-ran, so the routing cleared by the prior deactivate()'s clearCommunicationDevice() was never re-asserted and playout stayed on the earpiece until the next connect. Clear `audioSwitch` synchronously under the lock and mark it @Volatile so start() reliably observes the teardown and re-creates the switch. The switch's stop() is still posted to its handler thread, since AbstractAudioSwitch is not threadsafe. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/audioswitch-stale-switch-on-restart.md | 5 +++++ .../io/livekit/android/audio/AudioSwitchHandler.kt | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 .changeset/audioswitch-stale-switch-on-restart.md diff --git a/.changeset/audioswitch-stale-switch-on-restart.md b/.changeset/audioswitch-stale-switch-on-restart.md new file mode 100644 index 000000000..37994cb5a --- /dev/null +++ b/.changeset/audioswitch-stale-switch-on-restart.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": patch +--- + +Fix audio output getting stuck on the earpiece after reconnecting on a reused `Room` (Android 12+). `AudioSwitchHandler.stop()` now clears its `audioSwitch` reference synchronously (and the field is `@Volatile`) so a subsequent `start()` reliably observes the teardown and re-creates the switch, instead of racing the posted teardown runnable and reusing a stale, already-stopped switch. diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt b/livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt index d80f8bf1a..1ab517a62 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/audio/AudioSwitchHandler.kt @@ -199,6 +199,9 @@ constructor(private val context: Context) : AudioHandler { */ var forceHandleAudioRouting = false + // Volatile and nulled synchronously in stop() (rather than only inside the posted + // teardown runnable) so that a subsequent start() reliably observes the teardown. + @Volatile private var audioSwitch: AbstractAudioSwitch? = null // AudioSwitch is not threadsafe, so all calls should be done through a single thread. @@ -261,10 +264,17 @@ constructor(private val context: Context) : AudioHandler { @Synchronized override fun stop() { + // Null audioSwitch synchronously (under the lock) so a subsequent start() reliably + // observes the teardown and re-creates the switch. Previously it was nulled inside the + // posted runnable on the handler thread; with handler/thread torn down synchronously + // below, a fast start() could read a stale, already-stopped switch and skip re-creation, + // leaving audio routing broken (e.g. stuck on the earpiece) until the next connect. + // The switch's stop() is still posted, since AbstractAudioSwitch is not threadsafe. + val switchToStop = audioSwitch + audioSwitch = null handler?.removeCallbacksAndMessages(null) handler?.postAtFrontOfQueue { - audioSwitch?.stop() - audioSwitch = null + switchToStop?.stop() } thread?.quitSafely()