From bae5ae9390b144fa4726796897dc3e0f04a8023b Mon Sep 17 00:00:00 2001 From: "ajay.jg" Date: Thu, 20 Aug 2026 22:53:08 +0530 Subject: [PATCH 1/3] Add audio capability callback threading regression test --- ...ioCapabilitiesReceiverWrongThreadTest.java | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioCapabilitiesReceiverWrongThreadTest.java diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioCapabilitiesReceiverWrongThreadTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioCapabilitiesReceiverWrongThreadTest.java new file mode 100644 index 00000000000..f48efe3500b --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioCapabilitiesReceiverWrongThreadTest.java @@ -0,0 +1,204 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.audio; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.truth.Truth.assertThat; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.ContextWrapper; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioDeviceCallback; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; + +/** Tests that raw OEM audio-device callbacks are serialized on the receiver handler. */ +@RunWith(AndroidJUnit4.class) +@Config(sdk = 23) +public class AudioCapabilitiesReceiverWrongThreadTest { + + private static final String PLAYBACK_THREAD_NAME = "ExoPlayer:Playback"; + private static final String OEM_THREAD_NAME = "AudioMonitorHdmiThread"; + private static final long TEST_TIMEOUT_SECONDS = 5; + + private HandlerThread playbackThread; + private Handler playbackHandler; + + @Before + public void setUp() { + playbackThread = new HandlerThread(PLAYBACK_THREAD_NAME); + playbackThread.start(); + playbackHandler = new Handler(playbackThread.getLooper()); + } + + @After + public void tearDown() throws Exception { + playbackThread.quitSafely(); + playbackThread.join(TimeUnit.SECONDS.toMillis(TEST_TIMEOUT_SECONDS)); + } + + @Test + public void rawThreadDeviceAddition_notifiesListenerOnReceiverHandler() throws Exception { + assertRawThreadCallbackIsDeliveredOnReceiverHandler(/* added= */ true); + } + + @Test + public void rawThreadDeviceRemoval_notifiesListenerOnReceiverHandler() throws Exception { + assertRawThreadCallbackIsDeliveredOnReceiverHandler(/* added= */ false); + } + + private void assertRawThreadCallbackIsDeliveredOnReceiverHandler(boolean added) + throws Exception { + CapabilityContext context = + new CapabilityContext(ApplicationProvider.getApplicationContext()); + TrackingListener listener = new TrackingListener(); + AtomicReference callbackReference = new AtomicReference<>(); + AtomicReference receiverReference = new AtomicReference<>(); + + runOnPlayback( + () -> { + AudioCapabilitiesReceiver receiver = + new AudioCapabilitiesReceiver(context, listener); + listener.receiver = receiver; + receiver.register(); + receiverReference.set(receiver); + callbackReference.set(getPrivateField(receiver, "audioDeviceCallback")); + }); + + AtomicReference callbackFailure = + invokeFromRawOemThread(checkNotNull(callbackReference.get()), added); + runOnPlayback(() -> {}); + + assertThat(callbackFailure.get()).isNull(); + assertThat(listener.threadName).isEqualTo(PLAYBACK_THREAD_NAME); + assertThat(listener.looperName).isEqualTo(PLAYBACK_THREAD_NAME); + assertThat(listener.stateAtListener) + .isEqualTo(getPrivateField(receiverReference.get(), "audioCapabilities")); + } + + private AtomicReference invokeFromRawOemThread( + AudioDeviceCallback callback, boolean added) throws Exception { + AtomicReference callbackFailure = new AtomicReference<>(); + Thread oemThread = + new Thread( + () -> { + try { + assertThat(Looper.myLooper()).isNull(); + if (added) { + callback.onAudioDevicesAdded(new AudioDeviceInfo[0]); + } else { + callback.onAudioDevicesRemoved(new AudioDeviceInfo[0]); + } + } catch (Throwable throwable) { + callbackFailure.set(throwable); + } + }, + OEM_THREAD_NAME); + oemThread.start(); + oemThread.join(TimeUnit.SECONDS.toMillis(TEST_TIMEOUT_SECONDS)); + assertThat(oemThread.isAlive()).isFalse(); + return callbackFailure; + } + + private void runOnPlayback(Runnable action) throws Exception { + CountDownLatch complete = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + playbackHandler.post( + () -> { + try { + action.run(); + } catch (Throwable throwable) { + failure.set(throwable); + } finally { + complete.countDown(); + } + }); + assertThat(complete.await(TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + if (failure.get() != null) { + throw new AssertionError("Playback-thread action failed", failure.get()); + } + } + + @SuppressWarnings("unchecked") + private static T getPrivateField(Object target, String fieldName) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return (T) field.get(target); + } catch (ReflectiveOperationException exception) { + throw new AssertionError("Could not read private field " + fieldName, exception); + } + } + + private static final class TrackingListener implements AudioCapabilitiesReceiver.Listener { + @Nullable AudioCapabilitiesReceiver receiver; + String threadName; + String looperName; + AudioCapabilities stateAtListener; + + @Override + public void onAudioCapabilitiesChanged(AudioCapabilities audioCapabilities) { + threadName = Thread.currentThread().getName(); + Looper looper = Looper.myLooper(); + looperName = looper == null ? "null" : looper.getThread().getName(); + stateAtListener = getPrivateField(checkNotNull(receiver), "audioCapabilities"); + } + } + + private static final class CapabilityContext extends ContextWrapper { + private int nextEncoding = C.ENCODING_AC3; + + CapabilityContext(Context base) { + super(base); + } + + @Override + public Context getApplicationContext() { + return this; + } + + @Override + public Intent registerReceiver(BroadcastReceiver receiver, IntentFilter filter) { + if (receiver != null) { + return super.registerReceiver(receiver, filter); + } + int encoding = nextEncoding; + nextEncoding = nextEncoding == C.ENCODING_AC3 ? C.ENCODING_DTS : C.ENCODING_AC3; + return new Intent(AudioManager.ACTION_HDMI_AUDIO_PLUG) + .putExtra(AudioManager.EXTRA_AUDIO_PLUG_STATE, 1) + .putExtra(AudioManager.EXTRA_ENCODINGS, new int[] {encoding}) + .putExtra(AudioManager.EXTRA_MAX_CHANNEL_COUNT, 6); + } + } +} From f2b04be531b930912e9314f356f858c01a47821f Mon Sep 17 00:00:00 2001 From: "ajay.jg" Date: Thu, 20 Aug 2026 22:53:53 +0530 Subject: [PATCH 2/3] Serialize audio capability callbacks on receiver handler --- .../exoplayer/audio/AudioCapabilitiesReceiver.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioCapabilitiesReceiver.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioCapabilitiesReceiver.java index ea72450d504..a261f1a7b5e 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioCapabilitiesReceiver.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioCapabilitiesReceiver.java @@ -267,15 +267,18 @@ public void onChange(boolean selfChange) { private final class AudioDeviceCallback extends android.media.AudioDeviceCallback { @Override public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) { - updateCurrentAudioCapabilities(); + handler.post(AudioCapabilitiesReceiver.this::updateCurrentAudioCapabilities); } @Override public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) { - if (Util.contains(removedDevices, routedDevice)) { - routedDevice = null; - } - updateCurrentAudioCapabilities(); + handler.post( + () -> { + if (Util.contains(removedDevices, routedDevice)) { + routedDevice = null; + } + updateCurrentAudioCapabilities(); + }); } } } From 23205a151055cf72a793f05aa3526683ebd8d3b0 Mon Sep 17 00:00:00 2001 From: "ajay.jg" Date: Sat, 12 Sep 2026 10:43:58 +0530 Subject: [PATCH 3/3] Gate audio callback threading workaround to affected devices --- .../audio/AudioCapabilitiesReceiver.java | 25 ++++++++-- ...ioCapabilitiesReceiverWrongThreadTest.java | 46 ++++++++++++++++++- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioCapabilitiesReceiver.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioCapabilitiesReceiver.java index a261f1a7b5e..614b5968076 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioCapabilitiesReceiver.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/audio/AudioCapabilitiesReceiver.java @@ -27,9 +27,11 @@ import android.media.AudioDeviceInfo; import android.media.AudioManager; import android.net.Uri; +import android.os.Build; import android.os.Handler; import androidx.annotation.Nullable; import androidx.media3.common.AudioAttributes; +import androidx.media3.common.MediaLibraryInfo; import androidx.media3.common.audio.AudioManagerCompat; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; @@ -225,6 +227,14 @@ private void updateCurrentAudioCapabilities() { context, audioAttributes, routedDevice, spatializerChannelMasks)); } + private static boolean deviceNeedsAudioDeviceCallbackThreadingWorkaround() { + return MediaLibraryInfo.enableWorkarounds() + && SDK_INT < 38 + && (("Jio".equals(Build.MANUFACTURER) && "JHSA400".equals(Build.MODEL)) + || ("SkyworthDigital".equals(Build.MANUFACTURER) + && "XStream-Smart-Box-002".equals(Build.MODEL))); + } + private final class HdmiAudioPlugBroadcastReceiver extends BroadcastReceiver { @Override @@ -267,18 +277,27 @@ public void onChange(boolean selfChange) { private final class AudioDeviceCallback extends android.media.AudioDeviceCallback { @Override public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) { - handler.post(AudioCapabilitiesReceiver.this::updateCurrentAudioCapabilities); + if (deviceNeedsAudioDeviceCallbackThreadingWorkaround()) { + handler.post(AudioCapabilitiesReceiver.this::updateCurrentAudioCapabilities); + } else { + updateCurrentAudioCapabilities(); + } } @Override public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) { - handler.post( + Runnable updateAudioCapabilities = () -> { if (Util.contains(removedDevices, routedDevice)) { routedDevice = null; } updateCurrentAudioCapabilities(); - }); + }; + if (deviceNeedsAudioDeviceCallbackThreadingWorkaround()) { + handler.post(updateAudioCapabilities); + } else { + updateAudioCapabilities.run(); + } } } } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioCapabilitiesReceiverWrongThreadTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioCapabilitiesReceiverWrongThreadTest.java index f48efe3500b..70c6d0ea081 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioCapabilitiesReceiverWrongThreadTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioCapabilitiesReceiverWrongThreadTest.java @@ -26,11 +26,13 @@ import android.media.AudioDeviceCallback; import android.media.AudioDeviceInfo; import android.media.AudioManager; +import android.os.Build; import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; import androidx.annotation.Nullable; import androidx.media3.common.C; +import androidx.media3.common.MediaLibraryInfo; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import java.lang.reflect.Field; @@ -42,10 +44,11 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.annotation.Config; +import org.robolectric.util.ReflectionHelpers; /** Tests that raw OEM audio-device callbacks are serialized on the receiver handler. */ @RunWith(AndroidJUnit4.class) -@Config(sdk = 23) +@Config(sdk = 29) public class AudioCapabilitiesReceiverWrongThreadTest { private static final String PLAYBACK_THREAD_NAME = "ExoPlayer:Playback"; @@ -54,9 +57,18 @@ public class AudioCapabilitiesReceiverWrongThreadTest { private HandlerThread playbackThread; private Handler playbackHandler; + private String originalManufacturer; + private String originalModel; + private boolean originalEnableWorkarounds; @Before public void setUp() { + originalManufacturer = Build.MANUFACTURER; + originalModel = Build.MODEL; + originalEnableWorkarounds = MediaLibraryInfo.enableWorkarounds(); + ReflectionHelpers.setStaticField(Build.class, "MANUFACTURER", "SkyworthDigital"); + ReflectionHelpers.setStaticField(Build.class, "MODEL", "XStream-Smart-Box-002"); + MediaLibraryInfo.setEnableWorkarounds(true); playbackThread = new HandlerThread(PLAYBACK_THREAD_NAME); playbackThread.start(); playbackHandler = new Handler(playbackThread.getLooper()); @@ -64,6 +76,9 @@ public void setUp() { @After public void tearDown() throws Exception { + ReflectionHelpers.setStaticField(Build.class, "MANUFACTURER", originalManufacturer); + ReflectionHelpers.setStaticField(Build.class, "MODEL", originalModel); + MediaLibraryInfo.setEnableWorkarounds(originalEnableWorkarounds); playbackThread.quitSafely(); playbackThread.join(TimeUnit.SECONDS.toMillis(TEST_TIMEOUT_SECONDS)); } @@ -78,6 +93,35 @@ public void rawThreadDeviceRemoval_notifiesListenerOnReceiverHandler() throws Ex assertRawThreadCallbackIsDeliveredOnReceiverHandler(/* added= */ false); } + @Test + public void disabledWorkarounds_rawThreadDeviceAddition_notifiesListenerOnOemThread() + throws Exception { + MediaLibraryInfo.setEnableWorkarounds(false); + + CapabilityContext context = + new CapabilityContext(ApplicationProvider.getApplicationContext()); + TrackingListener listener = new TrackingListener(); + AtomicReference callbackReference = new AtomicReference<>(); + AtomicReference receiverReference = new AtomicReference<>(); + + runOnPlayback( + () -> { + AudioCapabilitiesReceiver receiver = new AudioCapabilitiesReceiver(context, listener); + listener.receiver = receiver; + receiver.register(); + receiverReference.set(receiver); + callbackReference.set(getPrivateField(receiver, "audioDeviceCallback")); + }); + + AtomicReference callbackFailure = + invokeFromRawOemThread(checkNotNull(callbackReference.get()), /* added= */ true); + + assertThat(callbackFailure.get()).isNull(); + assertThat(listener.threadName).isEqualTo(OEM_THREAD_NAME); + assertThat(listener.looperName).isEqualTo("null"); + runOnPlayback(() -> checkNotNull(receiverReference.get()).unregister()); + } + private void assertRawThreadCallbackIsDeliveredOnReceiverHandler(boolean added) throws Exception { CapabilityContext context =