From b7223ca0a43c1f6a5acc478ae3e8fbe173454082 Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 11 Oct 2026 17:42:36 +0900 Subject: [PATCH 1/4] android: port AudioSwitchHandler improvements and update audioswitch 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 --- .changeset/android-audioswitch-port.md | 5 + android/build.gradle | 2 +- .../reactnative/LivekitReactNativeModule.kt | 1 + .../reactnative/audio/AudioSwitchManager.java | 264 +++++++++++------- 4 files changed, 168 insertions(+), 104 deletions(-) create mode 100644 .changeset/android-audioswitch-port.md diff --git a/.changeset/android-audioswitch-port.md b/.changeset/android-audioswitch-port.md new file mode 100644 index 00000000..7803456e --- /dev/null +++ b/.changeset/android-audioswitch-port.md @@ -0,0 +1,5 @@ +--- +'@livekit/react-native': patch +--- + +Android: port AudioSwitchHandler improvements from client-sdk-android. Audio routing now runs on a dedicated background thread, uses the communication device API on Android 12+, fixes a race where a quick stop/start could leave audio stuck on the earpiece, and releases the audio session on React reload. diff --git a/android/build.gradle b/android/build.gradle index 83ea8830..2798dd4a 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -127,7 +127,7 @@ dependencies { // noinspection GradleDynamicVersion api 'com.facebook.react:react-native:+' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - api 'com.github.davidliu:audioswitch:89582c47c9a04c62f90aa5e57251af4800a62c9a' + api 'com.github.davidliu:audioswitch:039a35aefab7747c557242fa216c9ea11743b604' api 'io.github.webrtc-sdk:android-prefixed:144.7559.15' implementation "io.livekit:noise:2.0.0" implementation project(':livekit_react-native-webrtc') diff --git a/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt b/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt index ece4fcb5..0fcab382 100644 --- a/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt +++ b/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt @@ -230,6 +230,7 @@ class LivekitReactNativeModule(reactContext: ReactApplicationContext) : ReactCon } override fun invalidate() { + audioManager.stop() LiveKitReactNative.invalidate(reactApplicationContext) } } diff --git a/android/src/main/java/com/livekit/reactnative/audio/AudioSwitchManager.java b/android/src/main/java/com/livekit/reactnative/audio/AudioSwitchManager.java index 0e96a261..7b19cace 100644 --- a/android/src/main/java/com/livekit/reactnative/audio/AudioSwitchManager.java +++ b/android/src/main/java/com/livekit/reactnative/audio/AudioSwitchManager.java @@ -3,69 +3,69 @@ import android.content.Context; import android.media.AudioAttributes; import android.media.AudioManager; +import android.os.Build; import android.os.Handler; +import android.os.HandlerThread; import android.os.Looper; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.twilio.audioswitch.AbstractAudioSwitch; import com.twilio.audioswitch.AudioDevice; import com.twilio.audioswitch.AudioSwitch; +import com.twilio.audioswitch.CommDeviceAudioSwitch; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Objects; +import java.util.function.Consumer; import kotlin.Unit; -import kotlin.jvm.functions.Function2; public class AudioSwitchManager { + private static final String TAG = "AudioSwitchManager"; + @NonNull private final Context context; - @NonNull - private final AudioManager audioManager; public boolean loggingEnabled; - @NonNull - public Function2< - ? super List, - ? super AudioDevice, - Unit> audioDeviceChangeListener = (devices, currentDevice) -> null; @NonNull - public AudioManager.OnAudioFocusChangeListener audioFocusChangeListener = (i -> { - }); + private volatile List> preferredDeviceList; - @NonNull - public List> preferredDeviceList; - - // AudioSwitch is not threadsafe, so all calls should be done on the main thread. - private final Handler handler = new Handler(Looper.getMainLooper()); + // Volatile and nulled synchronously in stop() (rather than only inside the posted + // teardown runnable) so that a subsequent start() reliably observes the teardown. + @Nullable + private volatile AbstractAudioSwitch audioSwitch; + // AudioSwitch is not threadsafe, so all calls should be done through a single thread. + @Nullable + private volatile Handler handler; @Nullable - private AudioSwitch audioSwitch; + private HandlerThread thread; /** * When true, AudioSwitchHandler will request audio focus on start and abandon on stop. * * Defaults to true. */ - private boolean manageAudioFocus = true; + private volatile boolean manageAudioFocus = true; /** * The audio focus mode to use while started. * * Defaults to [AudioManager.AUDIOFOCUS_GAIN]. */ - private int focusMode = AudioManager.AUDIOFOCUS_GAIN; + private volatile int focusMode = AudioManager.AUDIOFOCUS_GAIN; /** * The audio mode to use while started. * * Defaults to AudioManager.MODE_IN_COMMUNICATION. */ - private int audioMode = AudioManager.MODE_IN_COMMUNICATION; + private volatile int audioMode = AudioManager.MODE_IN_COMMUNICATION; /** * The audio stream type to use when requesting audio focus on pre-O devices. @@ -77,7 +77,7 @@ public class AudioSwitchManager { * * Note: Manual audio routing may not work appropriately when using non-default values. */ - private int audioStreamType = AudioManager.STREAM_VOICE_CALL; + private volatile int audioStreamType = AudioManager.STREAM_VOICE_CALL; /** * The audio attribute usage type to use when requesting audio focus on devices O and beyond. @@ -89,7 +89,7 @@ public class AudioSwitchManager { * * Note: Manual audio routing may not work appropriately when using non-default values. */ - private int audioAttributeUsageType = AudioAttributes.USAGE_VOICE_COMMUNICATION; + private volatile int audioAttributeUsageType = AudioAttributes.USAGE_VOICE_COMMUNICATION; /** * The audio attribute content type to use when requesting audio focus on devices O and beyond. @@ -101,7 +101,7 @@ public class AudioSwitchManager { * * Note: Manual audio routing may not work appropriately when using non-default values. */ - private int audioAttributeContentType = AudioAttributes.CONTENT_TYPE_SPEECH; + private volatile int audioAttributeContentType = AudioAttributes.CONTENT_TYPE_SPEECH; /** * On certain Android devices, audio routing does not function properly and bluetooth microphones will not work @@ -111,59 +111,110 @@ public class AudioSwitchManager { * * If this set to true, AudioSwitchManager will attempt to do audio routing, though behavior is undefined. */ - private boolean forceHandleAudioRouting = false; + private volatile boolean forceHandleAudioRouting = false; public AudioSwitchManager(@NonNull Context context) { this.context = context; - this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); - preferredDeviceList = new ArrayList<>(); - preferredDeviceList.add(AudioDevice.BluetoothHeadset.class); - preferredDeviceList.add(AudioDevice.WiredHeadset.class); - preferredDeviceList.add(AudioDevice.Speakerphone.class); - preferredDeviceList.add(AudioDevice.Earpiece.class); + List> defaultList = new ArrayList<>(); + defaultList.add(AudioDevice.BluetoothHeadset.class); + defaultList.add(AudioDevice.WiredHeadset.class); + defaultList.add(AudioDevice.Speakerphone.class); + defaultList.add(AudioDevice.Earpiece.class); + preferredDeviceList = defaultList; } - public void start() { + public synchronized void start() { + if (handler != null || thread != null) { + Log.i(TAG, "AudioSwitchManager called start multiple times?"); + } + + HandlerThread currentThread = thread; + if (currentThread == null) { + currentThread = new HandlerThread("AudioSwitchManagerThread"); + currentThread.start(); + thread = currentThread; + } + Handler currentHandler = handler; + if (currentHandler == null) { + currentHandler = new Handler(currentThread.getLooper()); + handler = currentHandler; + } + 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(() -> { + AbstractAudioSwitch newAudioSwitch; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + newAudioSwitch = new CommDeviceAudioSwitch( + context, + loggingEnabled, + focusChange -> { + }, + preferredDeviceList + ); + } else { + newAudioSwitch = new AudioSwitch( + context, + loggingEnabled, + focusChange -> { + }, + preferredDeviceList + ); + } + newAudioSwitch.setManageAudioFocus(manageAudioFocus); + newAudioSwitch.setFocusMode(focusMode); + newAudioSwitch.setAudioMode(audioMode); + newAudioSwitch.setAudioStreamType(audioStreamType); + newAudioSwitch.setAudioAttributeContentType(audioAttributeContentType); + newAudioSwitch.setAudioAttributeUsageType(audioAttributeUsageType); + newAudioSwitch.setForceHandleAudioRouting(forceHandleAudioRouting); + + // stop() may have run while this switch was being created; its posted teardown + // saw no switch to stop. Publish under the lock only if this start is still current, + // otherwise drop the switch (nothing is registered until start() below). + // Once published, a later stop() posts its teardown behind this runnable. + synchronized (AudioSwitchManager.this) { + if (handler != startHandler) { + return; + } + audioSwitch = newAudioSwitch; + } + newAudioSwitch.start((devices, currentDevice) -> Unit.INSTANCE); + newAudioSwitch.activate(); }); } } - public void stop() { - handler.removeCallbacksAndMessages(null); - handler.postAtFrontOfQueue(() -> { - if (audioSwitch != null) { - audioSwitch.stop(); - } - audioSwitch = null; - }); - } + public synchronized void stop() { + // Null audioSwitch synchronously (under the lock) so a subsequent start() reliably + // observes the teardown and re-creates the switch. The switch's stop() is still posted, + // since AbstractAudioSwitch is not threadsafe. + AbstractAudioSwitch switchToStop = audioSwitch; + audioSwitch = null; + + Handler currentHandler = handler; + if (currentHandler != null) { + currentHandler.removeCallbacksAndMessages(null); + currentHandler.postAtFrontOfQueue(() -> { + if (switchToStop != null) { + switchToStop.stop(); + } + }); + } + HandlerThread currentThread = thread; + if (currentThread != null) { + currentThread.quitSafely(); + } - public void setMicrophoneMute(boolean mute) { - audioManager.setMicrophoneMute(mute); + handler = null; + thread = null; } @Nullable public AudioDevice selectedAudioDevice() { - AudioSwitch audioSwitchTemp = audioSwitch; + AbstractAudioSwitch audioSwitchTemp = audioSwitch; if (audioSwitchTemp != null) { return audioSwitchTemp.getSelectedAudioDevice(); } else { @@ -173,7 +224,7 @@ public AudioDevice selectedAudioDevice() { @NonNull public List availableAudioDevices() { - AudioSwitch audioSwitchTemp = audioSwitch; + AbstractAudioSwitch audioSwitchTemp = audioSwitch; if (audioSwitchTemp != null) { return audioSwitchTemp.getAvailableAudioDevices(); } else { @@ -182,85 +233,92 @@ public List availableAudioDevices() { } public void selectAudioOutput(@NonNull Class audioDeviceClass) { - handler.post(() -> { - if (audioSwitch != null) { - List devices = availableAudioDevices(); - AudioDevice audioDevice = null; - - for (AudioDevice device : devices) { - if (device.getClass().equals(audioDeviceClass)) { - audioDevice = device; - break; - } - } - - if (audioDevice != null) { - audioSwitch.selectDevice(audioDevice); + runOnAudioSwitch(sw -> { + for (AudioDevice device : sw.getAvailableAudioDevices()) { + if (device.getClass().equals(audioDeviceClass)) { + sw.selectDevice(device); + return; } } }); } - public void enableSpeakerphone(boolean enable) { - if (enable) { - audioManager.setSpeakerphoneOn(true); - } else { - audioManager.setSpeakerphoneOn(false); - } - } - public void selectAudioOutput(@Nullable AudioDeviceKind kind) { if (kind != null) { selectAudioOutput(kind.audioDeviceClass); } } + @NonNull + public List> getPreferredDeviceList() { + return preferredDeviceList; + } + + /** + * The preferred priority of audio devices to use. The first available audio device will be used. + * + * Changes to this value after [start] has been called will still be applied + * to the underlying audio switch. + */ + public void setPreferredDeviceList(@NonNull List> preferredDeviceList) { + this.preferredDeviceList = preferredDeviceList; + runOnAudioSwitch(sw -> sw.setPreferredDeviceList(preferredDeviceList)); + } + public void setManageAudioFocus(boolean manage) { this.manageAudioFocus = manage; - if (audioSwitch != null) { - Objects.requireNonNull(audioSwitch).setManageAudioFocus(this.manageAudioFocus); - } + runOnAudioSwitch(sw -> sw.setManageAudioFocus(manage)); } public void setFocusMode(int focusMode) { this.focusMode = focusMode; - if (audioSwitch != null) { - Objects.requireNonNull(audioSwitch).setFocusMode(this.focusMode); - } + runOnAudioSwitch(sw -> sw.setFocusMode(focusMode)); } public void setAudioMode(int audioMode) { this.audioMode = audioMode; - if (audioSwitch != null) { - Objects.requireNonNull(audioSwitch).setAudioMode(this.audioMode); - } + runOnAudioSwitch(sw -> sw.setAudioMode(audioMode)); } public void setAudioStreamType(int streamType) { this.audioStreamType = streamType; - if (audioSwitch != null) { - Objects.requireNonNull(audioSwitch).setAudioStreamType(this.audioStreamType); - } + runOnAudioSwitch(sw -> sw.setAudioStreamType(streamType)); } public void setAudioAttributesUsageType(int usageType) { this.audioAttributeUsageType = usageType; - if (audioSwitch != null) { - Objects.requireNonNull(audioSwitch).setAudioAttributeUsageType(this.audioAttributeUsageType); - } + runOnAudioSwitch(sw -> sw.setAudioAttributeUsageType(usageType)); } public void setAudioAttributesContentType(int contentType) { this.audioAttributeContentType = contentType; - if (audioSwitch != null) { - Objects.requireNonNull(audioSwitch).setAudioAttributeContentType(this.audioAttributeContentType); - } + runOnAudioSwitch(sw -> sw.setAudioAttributeContentType(contentType)); } public void setForceHandleAudioRouting(boolean force) { this.forceHandleAudioRouting = force; - if (audioSwitch != null) { - Objects.requireNonNull(audioSwitch).setForceHandleAudioRouting(this.forceHandleAudioRouting); + runOnAudioSwitch(sw -> sw.setForceHandleAudioRouting(force)); + } + + /** + * Runs the action against the current audio switch on the audio switch thread. + * No-op if not started. + */ + private void runOnAudioSwitch(@NonNull Consumer action) { + Handler currentHandler = handler; + if (currentHandler == null) { + return; + } + Runnable runnable = () -> { + AbstractAudioSwitch currentSwitch = audioSwitch; + if (currentSwitch != null) { + action.accept(currentSwitch); + } + }; + if (Looper.myLooper() == currentHandler.getLooper()) { + runnable.run(); + } else { + currentHandler.post(runnable); } } } From 8a6c93c4493e022c6f6631c16b8d7be8f88e15bb Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:58:49 +0800 Subject: [PATCH 2/4] android: drop a switch created by a superseded start() 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. --- .../reactnative/audio/AudioSwitchManager.java | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/android/src/main/java/com/livekit/reactnative/audio/AudioSwitchManager.java b/android/src/main/java/com/livekit/reactnative/audio/AudioSwitchManager.java index 7b19cace..de28268c 100644 --- a/android/src/main/java/com/livekit/reactnative/audio/AudioSwitchManager.java +++ b/android/src/main/java/com/livekit/reactnative/audio/AudioSwitchManager.java @@ -46,6 +46,10 @@ public class AudioSwitchManager { @Nullable private HandlerThread thread; + // Bumped by every start() and stop(), guarded by this. A switch created on the audio thread + // is only published if no other start() or stop() has run since its creation was queued. + private int generation; + /** * When true, AudioSwitchHandler will request audio focus on start and abandon on stop. * @@ -142,9 +146,9 @@ public synchronized void start() { } if (audioSwitch == null) { - final Handler startHandler = currentHandler; - startHandler.removeCallbacksAndMessages(null); - startHandler.postAtFrontOfQueue(() -> { + final int startGeneration = ++generation; + currentHandler.removeCallbacksAndMessages(null); + currentHandler.postAtFrontOfQueue(() -> { AbstractAudioSwitch newAudioSwitch; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { newAudioSwitch = new CommDeviceAudioSwitch( @@ -171,12 +175,14 @@ public synchronized void start() { newAudioSwitch.setAudioAttributeUsageType(audioAttributeUsageType); newAudioSwitch.setForceHandleAudioRouting(forceHandleAudioRouting); - // stop() may have run while this switch was being created; its posted teardown - // saw no switch to stop. Publish under the lock only if this start is still current, - // otherwise drop the switch (nothing is registered until start() below). + // stop() or another start() may have run while this switch was being created. + // A stop() found no switch to tear down, and a second start() queued its own + // creation, which would otherwise overwrite this switch and leave it active. + // Publish under the lock only if this start is still current, otherwise drop the + // switch (nothing is registered until start() below). // Once published, a later stop() posts its teardown behind this runnable. synchronized (AudioSwitchManager.this) { - if (handler != startHandler) { + if (generation != startGeneration) { return; } audioSwitch = newAudioSwitch; @@ -193,6 +199,7 @@ public synchronized void stop() { // since AbstractAudioSwitch is not threadsafe. AbstractAudioSwitch switchToStop = audioSwitch; audioSwitch = null; + generation++; Handler currentHandler = handler; if (currentHandler != null) { From 6e5211c2e15ff6eb2d2fba9293839a33fe91461e Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:59:42 +0800 Subject: [PATCH 3/4] android: run every audio session on one AudioSwitch thread 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. --- .../reactnative/LivekitReactNativeModule.kt | 2 +- .../reactnative/audio/AudioSwitchManager.java | 31 ++++++++++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt b/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt index 0fcab382..f77488a8 100644 --- a/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt +++ b/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt @@ -230,7 +230,7 @@ class LivekitReactNativeModule(reactContext: ReactApplicationContext) : ReactCon } override fun invalidate() { - audioManager.stop() + audioManager.release() LiveKitReactNative.invalidate(reactApplicationContext) } } diff --git a/android/src/main/java/com/livekit/reactnative/audio/AudioSwitchManager.java b/android/src/main/java/com/livekit/reactnative/audio/AudioSwitchManager.java index de28268c..ece0b221 100644 --- a/android/src/main/java/com/livekit/reactnative/audio/AudioSwitchManager.java +++ b/android/src/main/java/com/livekit/reactnative/audio/AudioSwitchManager.java @@ -41,6 +41,8 @@ public class AudioSwitchManager { private volatile AbstractAudioSwitch audioSwitch; // AudioSwitch is not threadsafe, so all calls should be done through a single thread. + // The thread is kept until release() so that a session's teardown always runs before + // the next session's switch is created. @Nullable private volatile Handler handler; @Nullable @@ -129,7 +131,7 @@ public AudioSwitchManager(@NonNull Context context) { } public synchronized void start() { - if (handler != null || thread != null) { + if (audioSwitch != null) { Log.i(TAG, "AudioSwitchManager called start multiple times?"); } @@ -147,8 +149,9 @@ public synchronized void start() { if (audioSwitch == null) { final int startGeneration = ++generation; - currentHandler.removeCallbacksAndMessages(null); - currentHandler.postAtFrontOfQueue(() -> { + // Posted in order, not at the front of the queue, so it runs after the teardown + // of a previous session that may still be pending. + currentHandler.post(() -> { AbstractAudioSwitch newAudioSwitch; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { newAudioSwitch = new CommDeviceAudioSwitch( @@ -196,25 +199,29 @@ public synchronized void start() { public synchronized void stop() { // Null audioSwitch synchronously (under the lock) so a subsequent start() reliably // observes the teardown and re-creates the switch. The switch's stop() is still posted, - // since AbstractAudioSwitch is not threadsafe. + // since AbstractAudioSwitch is not threadsafe. It is queued on the same thread as the + // next start()'s creation, so the old switch never restores audio state over the new one. AbstractAudioSwitch switchToStop = audioSwitch; audioSwitch = null; generation++; Handler currentHandler = handler; - if (currentHandler != null) { - currentHandler.removeCallbacksAndMessages(null); - currentHandler.postAtFrontOfQueue(() -> { - if (switchToStop != null) { - switchToStop.stop(); - } - }); + if (currentHandler != null && switchToStop != null) { + currentHandler.post(switchToStop::stop); } + } + + /** + * Stops the audio session and shuts down the audio switch thread. + */ + public synchronized void release() { + stop(); + + // quitSafely() still runs the teardown that stop() just posted. HandlerThread currentThread = thread; if (currentThread != null) { currentThread.quitSafely(); } - handler = null; thread = null; } From 0b775cac0881abdc5d1fedb864bf83e73be269d8 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:13:40 +0800 Subject: [PATCH 4/4] android: add Robolectric tests for AudioSwitchManager start/stop races 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. --- android/build.gradle | 4 + .../audio/AudioSwitchManagerTest.java | 186 ++++++++++++++++++ package.json | 1 + 3 files changed, 191 insertions(+) create mode 100644 android/src/test/java/com/livekit/reactnative/audio/AudioSwitchManagerTest.java diff --git a/android/build.gradle b/android/build.gradle index fa5122f5..bf0be103 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -138,4 +138,8 @@ dependencies { implementation "io.livekit:noise:2.0.0" implementation project(':livekit_react-native-webrtc') implementation "androidx.annotation:annotation:1.9.1" + + testImplementation "junit:junit:4.13.2" + testImplementation "org.robolectric:robolectric:4.14.1" + testImplementation "androidx.test:core:1.6.1" } diff --git a/android/src/test/java/com/livekit/reactnative/audio/AudioSwitchManagerTest.java b/android/src/test/java/com/livekit/reactnative/audio/AudioSwitchManagerTest.java new file mode 100644 index 00000000..4aa7f996 --- /dev/null +++ b/android/src/test/java/com/livekit/reactnative/audio/AudioSwitchManagerTest.java @@ -0,0 +1,186 @@ +package com.livekit.reactnative.audio; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import android.content.Context; +import android.content.ContextWrapper; +import android.media.AudioManager; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Start/stop races in AudioSwitchManager. Each test blocks the audio thread at the point where + * the race happens, then checks the audio mode the switches leave behind. + * + * SDK 30 uses AudioSwitch and SDK 33 uses CommDeviceAudioSwitch. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = {30, 33}) +public class AudioSwitchManagerTest { + + private AudioManager audioManager; + private GatedContext context; + + @Before + public void setUp() { + Context app = ApplicationProvider.getApplicationContext(); + audioManager = (AudioManager) app.getSystemService(Context.AUDIO_SERVICE); + audioManager.setMode(AudioManager.MODE_NORMAL); + context = new GatedContext(app); + } + + @Test + public void quickStopStartEndsActive() throws Exception { + AudioSwitchManager manager = new AudioSwitchManager(context); + manager.start(); + settle(manager); + manager.stop(); + manager.start(); + settle(manager); + + assertNotNull(field(manager, "audioSwitch")); + assertEquals(AudioManager.MODE_IN_COMMUNICATION, audioManager.getMode()); + } + + @Test + public void stopDuringCreationDropsSwitch() throws Exception { + AudioSwitchManager manager = new AudioSwitchManager(context); + context.blockNextCreation(); + manager.start(); + await(context.creating); + manager.stop(); + context.unblock.countDown(); + settle(manager); + + assertNull(field(manager, "audioSwitch")); + assertEquals(AudioManager.MODE_NORMAL, audioManager.getMode()); + } + + @Test + public void secondStartDuringCreationLeavesNoSwitchActiveAfterStop() throws Exception { + AudioSwitchManager manager = new AudioSwitchManager(context); + context.blockNextCreation(); + manager.start(); + await(context.creating); + manager.start(); + context.unblock.countDown(); + settle(manager); + assertEquals(AudioManager.MODE_IN_COMMUNICATION, audioManager.getMode()); + + manager.stop(); + settle(manager); + assertEquals(AudioManager.MODE_NORMAL, audioManager.getMode()); + } + + @Test + public void lateTeardownDoesNotUndoNextSession() throws Exception { + AudioSwitchManager manager = new AudioSwitchManager(context); + manager.start(); + settle(manager); + + // Keep the audio thread busy so the teardown posted by stop() runs late. + CountDownLatch busy = new CountDownLatch(1); + CountDownLatch unblock = new CountDownLatch(1); + handlerOf(manager).post(() -> { + busy.countDown(); + await(unblock); + }); + await(busy); + manager.stop(); + manager.start(); + unblock.countDown(); + settle(manager); + assertEquals(AudioManager.MODE_IN_COMMUNICATION, audioManager.getMode()); + + manager.stop(); + settle(manager); + assertEquals(AudioManager.MODE_NORMAL, audioManager.getMode()); + } + + @Test + public void releaseStopsSessionAndThread() throws Exception { + AudioSwitchManager manager = new AudioSwitchManager(context); + manager.start(); + settle(manager); + HandlerThread thread = (HandlerThread) field(manager, "thread"); + + manager.release(); + thread.join(TimeUnit.SECONDS.toMillis(5)); + + assertFalse(thread.isAlive()); + assertEquals(AudioManager.MODE_NORMAL, audioManager.getMode()); + } + + /** + * Can block the next switch construction, which looks up the audio service on the + * audio thread. + */ + private static class GatedContext extends ContextWrapper { + private final AtomicBoolean armed = new AtomicBoolean(false); + final CountDownLatch creating = new CountDownLatch(1); + final CountDownLatch unblock = new CountDownLatch(1); + + GatedContext(Context base) { + super(base); + } + + void blockNextCreation() { + armed.set(true); + } + + @Override + public Object getSystemService(String name) { + if (Context.AUDIO_SERVICE.equals(name) + && Looper.myLooper() != Looper.getMainLooper() + && armed.compareAndSet(true, false)) { + creating.countDown(); + await(unblock); + } + return super.getSystemService(name); + } + } + + /** Waits until everything queued on the audio thread so far has run. */ + private static void settle(AudioSwitchManager manager) throws Exception { + CountDownLatch done = new CountDownLatch(1); + handlerOf(manager).post(done::countDown); + await(done); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("Timed out waiting for the audio thread"); + } + } catch (InterruptedException e) { + throw new AssertionError(e); + } + } + + private static Handler handlerOf(AudioSwitchManager manager) throws Exception { + return (Handler) field(manager, "handler"); + } + + private static Object field(AudioSwitchManager manager, String name) throws Exception { + Field f = AudioSwitchManager.class.getDeclaredField(name); + f.setAccessible(true); + return f.get(manager); + } +} diff --git a/package.json b/package.json index d5d94b2e..35c83c31 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "!android/gradlew", "!android/gradlew.bat", "!android/local.properties", + "!android/src/test", "!ios/build", "!example", "!ci",