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 5f21371d..bf0be103 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -133,9 +133,13 @@ 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:150.7871.01' 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/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt b/android/src/main/java/com/livekit/reactnative/LivekitReactNativeModule.kt index ece4fcb5..f77488a8 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.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 0e96a261..ece0b221 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,75 @@ 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 -> { - }); @NonNull - public List> preferredDeviceList; + private volatile 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. + // The thread is kept until release() so that a session's teardown always runs before + // the next session's switch is created. @Nullable - private AudioSwitch audioSwitch; + private volatile Handler handler; + @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. * * 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 +83,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 +95,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 +107,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 +117,118 @@ 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 (audioSwitch != 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 int startGeneration = ++generation; + // 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( + 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() 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 (generation != startGeneration) { + 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. 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 && switchToStop != null) { + currentHandler.post(switchToStop::stop); + } } - public void setMicrophoneMute(boolean mute) { - audioManager.setMicrophoneMute(mute); + /** + * 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; } @Nullable public AudioDevice selectedAudioDevice() { - AudioSwitch audioSwitchTemp = audioSwitch; + AbstractAudioSwitch audioSwitchTemp = audioSwitch; if (audioSwitchTemp != null) { return audioSwitchTemp.getSelectedAudioDevice(); } else { @@ -173,7 +238,7 @@ public AudioDevice selectedAudioDevice() { @NonNull public List availableAudioDevices() { - AudioSwitch audioSwitchTemp = audioSwitch; + AbstractAudioSwitch audioSwitchTemp = audioSwitch; if (audioSwitchTemp != null) { return audioSwitchTemp.getAvailableAudioDevices(); } else { @@ -182,85 +247,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); } } } 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",