From c7aec2eaeee043eb82f80898dd23d2aeeb6105bd Mon Sep 17 00:00:00 2001 From: "ajay.jg" Date: Thu, 20 Aug 2026 22:53:08 +0530 Subject: [PATCH 1/8] 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 af8bec49ae37cdeb1434a5ae0b5ae183cc60bd73 Mon Sep 17 00:00:00 2001 From: "ajay.jg" Date: Thu, 20 Aug 2026 22:53:53 +0530 Subject: [PATCH 2/8] 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 73d5e5e790284e8f6a019110cb777c6498835f8e Mon Sep 17 00:00:00 2001 From: "ajay.jg" Date: Sat, 12 Sep 2026 10:43:58 +0530 Subject: [PATCH 3/8] 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 = From 4cd16c8d8a490aef5df11f558035c95848d8dc5f Mon Sep 17 00:00:00 2001 From: Ian Baker Date: Mon, 21 Sep 2026 16:22:05 +0100 Subject: [PATCH 4/8] Switch to Util.postOrRun and Objects.equals --- .../exoplayer/audio/AudioCapabilitiesReceiver.java | 10 +++++----- 1 file changed, 5 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 614b5968076..97b69d4a913 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 @@ -230,9 +230,9 @@ private void updateCurrentAudioCapabilities() { 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))); + && ((Objects.equals(Build.MANUFACTURER, "Jio") && Objects.equals(Build.MODEL, "JHSA400")) + || (Objects.equals(Build.MANUFACTURER, "SkyworthDigital") + && Objects.equals(Build.MODEL, "XStream-Smart-Box-002"))); } private final class HdmiAudioPlugBroadcastReceiver extends BroadcastReceiver { @@ -278,7 +278,7 @@ private final class AudioDeviceCallback extends android.media.AudioDeviceCallbac @Override public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) { if (deviceNeedsAudioDeviceCallbackThreadingWorkaround()) { - handler.post(AudioCapabilitiesReceiver.this::updateCurrentAudioCapabilities); + Util.postOrRun(handler, AudioCapabilitiesReceiver.this::updateCurrentAudioCapabilities); } else { updateCurrentAudioCapabilities(); } @@ -294,7 +294,7 @@ public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) { updateCurrentAudioCapabilities(); }; if (deviceNeedsAudioDeviceCallbackThreadingWorkaround()) { - handler.post(updateAudioCapabilities); + Util.postOrRun(handler, updateAudioCapabilities); } else { updateAudioCapabilities.run(); } From acc6a4f6470577512a0e2824d3607e191638cbdf Mon Sep 17 00:00:00 2001 From: Ian Baker Date: Mon, 21 Sep 2026 16:52:48 +0100 Subject: [PATCH 5/8] Update the test to not need any reflection --- .../audio/AudioCapabilitiesReceiver.java | 3 +- ...ioCapabilitiesReceiverWrongThreadTest.java | 281 ++++++------------ 2 files changed, 96 insertions(+), 188 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 97b69d4a913..cd2eef1ce8c 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 @@ -30,6 +30,7 @@ import android.os.Build; import android.os.Handler; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.media3.common.AudioAttributes; import androidx.media3.common.MediaLibraryInfo; import androidx.media3.common.audio.AudioManagerCompat; @@ -61,7 +62,7 @@ public interface Listener { private final Context context; private final Listener listener; private final Handler handler; - private final AudioDeviceCallback audioDeviceCallback; + @VisibleForTesting final android.media.AudioDeviceCallback audioDeviceCallback; private final BroadcastReceiver hdmiAudioPlugBroadcastReceiver; @Nullable private final ExternalSurroundSoundSettingObserver externalSurroundSoundSettingObserver; @Nullable private SpatializerWrapper spatializer; 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 70c6d0ea081..7db98abf179 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 @@ -15,234 +15,141 @@ */ package androidx.media3.exoplayer.audio; -import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.Shadows.shadowOf; -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.Build; -import android.os.Handler; -import android.os.HandlerThread; import android.os.Looper; -import androidx.annotation.Nullable; +import androidx.media3.common.AudioAttributes; 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; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Sets; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.annotation.Config; -import org.robolectric.util.ReflectionHelpers; +import org.robolectric.shadows.ShadowBuild; /** Tests that raw OEM audio-device callbacks are serialized on the receiver handler. */ @RunWith(AndroidJUnit4.class) -@Config(sdk = 29) -public class AudioCapabilitiesReceiverWrongThreadTest { +@Config(maxSdk = 37) // The workaround is disabled from API 38+ +public final 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 static final AudioCapabilities OVERRIDDEN_AUDIO_CAPABILITIES = + new AudioCapabilities( + new int[] {C.ENCODING_AC3}, + /* maxChannelCount= */ 6, + /* speakerLayoutChannelMasks= */ ImmutableList.of(), + /* spatializerChannelMasks= */ ImmutableList.of()); - private HandlerThread playbackThread; - private Handler playbackHandler; - private String originalManufacturer; - private String originalModel; - private boolean originalEnableWorkarounds; + private ExecutorService wrongThreadExecutor; @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()); + ShadowBuild.setManufacturer("SkyworthDigital"); + ShadowBuild.setModel("XStream-Smart-Box-002"); + wrongThreadExecutor = Executors.newSingleThreadExecutor(); } @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)); + public void tearDown() { + wrongThreadExecutor.shutdownNow(); } @Test - public void rawThreadDeviceAddition_notifiesListenerOnReceiverHandler() throws Exception { - assertRawThreadCallbackIsDeliveredOnReceiverHandler(/* added= */ true); - } - - @Test - public void rawThreadDeviceRemoval_notifiesListenerOnReceiverHandler() throws Exception { - assertRawThreadCallbackIsDeliveredOnReceiverHandler(/* added= */ false); + public void onAudioDevicesAdded_fromBackgroundThreadWithoutLooper_notifiesListenerOnLooperThread() + throws Exception { + TrackingListener listener = new TrackingListener(); + AudioCapabilitiesReceiver audioCapabilitiesReceiver = + new AudioCapabilitiesReceiver( + ApplicationProvider.getApplicationContext(), + listener, + new AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).build(), + /* routedDevice= */ null); + AudioCapabilities unused = audioCapabilitiesReceiver.register(); + audioCapabilitiesReceiver.overrideCapabilities(OVERRIDDEN_AUDIO_CAPABILITIES); + + wrongThreadExecutor + .submit( + () -> + audioCapabilitiesReceiver.audioDeviceCallback.onAudioDevicesAdded( + new AudioDeviceInfo[0])) + .get(); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(listener.invocationCount.get()).isEqualTo(3); + assertThat(listener.looperlessThreads).isEmpty(); } @Test - public void disabledWorkarounds_rawThreadDeviceAddition_notifiesListenerOnOemThread() - throws Exception { - MediaLibraryInfo.setEnableWorkarounds(false); - - CapabilityContext context = - new CapabilityContext(ApplicationProvider.getApplicationContext()); + public void + onAudioDevicesRemoved_fromBackgroundThreadWithoutLooper_notifiesListenerOnLooperThread() + throws Exception { 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()); + AudioCapabilitiesReceiver audioCapabilitiesReceiver = + new AudioCapabilitiesReceiver( + ApplicationProvider.getApplicationContext(), + listener, + new AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).build(), + /* routedDevice= */ null); + AudioCapabilities unused = audioCapabilitiesReceiver.register(); + audioCapabilitiesReceiver.overrideCapabilities(OVERRIDDEN_AUDIO_CAPABILITIES); + + wrongThreadExecutor + .submit( + () -> + audioCapabilitiesReceiver.audioDeviceCallback.onAudioDevicesRemoved( + new AudioDeviceInfo[0])) + .get(); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(listener.invocationCount.get()).isEqualTo(3); + assertThat(listener.looperlessThreads).isEmpty(); } - private void assertRawThreadCallbackIsDeliveredOnReceiverHandler(boolean added) + @Test + public void onAudioDevicesAdded_onNonWorkaroundDevice_notifiesListenerOnCallingLooperlessThread() throws Exception { - CapabilityContext context = - new CapabilityContext(ApplicationProvider.getApplicationContext()); + ShadowBuild.setModel("OtherModel"); 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); - } + AudioCapabilitiesReceiver audioCapabilitiesReceiver = + new AudioCapabilitiesReceiver( + ApplicationProvider.getApplicationContext(), + listener, + new AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).build(), + /* routedDevice= */ null); + AudioCapabilities unused = audioCapabilitiesReceiver.register(); + audioCapabilitiesReceiver.overrideCapabilities(OVERRIDDEN_AUDIO_CAPABILITIES); + + wrongThreadExecutor + .submit( + () -> + audioCapabilitiesReceiver.audioDeviceCallback.onAudioDevicesAdded( + new AudioDeviceInfo[0])) + .get(); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(listener.invocationCount.get()).isEqualTo(3); + assertThat(listener.looperlessThreads).hasSize(1); } private static final class TrackingListener implements AudioCapabilitiesReceiver.Listener { - @Nullable AudioCapabilitiesReceiver receiver; - String threadName; - String looperName; - AudioCapabilities stateAtListener; + private final Set looperlessThreads = Sets.newConcurrentHashSet(); + private final AtomicInteger invocationCount = new AtomicInteger(); @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); + invocationCount.incrementAndGet(); + if (Looper.myLooper() == null) { + looperlessThreads.add(Thread.currentThread()); } - 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 407d5ceeeb08286605af3d7c6ba04cf2ddd196d0 Mon Sep 17 00:00:00 2001 From: Ian Baker Date: Tue, 22 Sep 2026 09:19:43 +0100 Subject: [PATCH 6/8] Add release note --- RELEASENOTES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index da99134082e..2acb2c0825d 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -103,6 +103,9 @@ `MetadataRetriever`. * Inspector Frame: * Audio: + * Add workaround for devices that invoke + `android.media.AudioDeviceCallback` on the wrong thread + ([#3386](https://github.com/androidx/media/issues/3386)). * Video: * Fix reporting of late video frames with identical release timestamps so that they are reported as dropped instead of skipped. From 00d628d8203540da4fb91900707561f4dff6e88a Mon Sep 17 00:00:00 2001 From: Ian Baker Date: Wed, 23 Sep 2026 12:34:10 +0100 Subject: [PATCH 7/8] Simplify impl to always call postOrRun --- .../audio/AudioCapabilitiesReceiver.java | 32 ++++++------------- ...ioCapabilitiesReceiverWrongThreadTest.java | 21 ++++-------- 2 files changed, 16 insertions(+), 37 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 cd2eef1ce8c..6c4c8cf95ec 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,12 +27,10 @@ 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.annotation.VisibleForTesting; 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; @@ -228,14 +226,6 @@ private void updateCurrentAudioCapabilities() { context, audioAttributes, routedDevice, spatializerChannelMasks)); } - private static boolean deviceNeedsAudioDeviceCallbackThreadingWorkaround() { - return MediaLibraryInfo.enableWorkarounds() - && SDK_INT < 38 - && ((Objects.equals(Build.MANUFACTURER, "Jio") && Objects.equals(Build.MODEL, "JHSA400")) - || (Objects.equals(Build.MANUFACTURER, "SkyworthDigital") - && Objects.equals(Build.MODEL, "XStream-Smart-Box-002"))); - } - private final class HdmiAudioPlugBroadcastReceiver extends BroadcastReceiver { @Override @@ -278,27 +268,25 @@ public void onChange(boolean selfChange) { private final class AudioDeviceCallback extends android.media.AudioDeviceCallback { @Override public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) { - if (deviceNeedsAudioDeviceCallbackThreadingWorkaround()) { - Util.postOrRun(handler, AudioCapabilitiesReceiver.this::updateCurrentAudioCapabilities); - } else { - updateCurrentAudioCapabilities(); - } + // Some devices are known to invoke this callback on the wrong thread: + // https://github.com/androidx/media/issues/3386. + // This workaround can be removed when minSdk is bumped to API 38. + Util.postOrRun(handler, AudioCapabilitiesReceiver.this::updateCurrentAudioCapabilities); } @Override public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) { - Runnable updateAudioCapabilities = + // Some devices are known to invoke this callback on the wrong thread: + // https://github.com/androidx/media/issues/3386. + // This workaround can be removed when minSdk is bumped to API 38. + Util.postOrRun( + handler, () -> { if (Util.contains(removedDevices, routedDevice)) { routedDevice = null; } updateCurrentAudioCapabilities(); - }; - if (deviceNeedsAudioDeviceCallbackThreadingWorkaround()) { - Util.postOrRun(handler, 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 7db98abf179..897cb64c273 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 @@ -35,11 +35,9 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.annotation.Config; -import org.robolectric.shadows.ShadowBuild; /** Tests that raw OEM audio-device callbacks are serialized on the receiver handler. */ @RunWith(AndroidJUnit4.class) -@Config(maxSdk = 37) // The workaround is disabled from API 38+ public final class AudioCapabilitiesReceiverWrongThreadTest { private static final AudioCapabilities OVERRIDDEN_AUDIO_CAPABILITIES = @@ -53,8 +51,6 @@ public final class AudioCapabilitiesReceiverWrongThreadTest { @Before public void setUp() { - ShadowBuild.setManufacturer("SkyworthDigital"); - ShadowBuild.setModel("XStream-Smart-Box-002"); wrongThreadExecutor = Executors.newSingleThreadExecutor(); } @@ -64,6 +60,7 @@ public void tearDown() { } @Test + @Config(maxSdk = 37) // The workaround isn't needed from API 38+ public void onAudioDevicesAdded_fromBackgroundThreadWithoutLooper_notifiesListenerOnLooperThread() throws Exception { TrackingListener listener = new TrackingListener(); @@ -89,6 +86,7 @@ public void onAudioDevicesAdded_fromBackgroundThreadWithoutLooper_notifiesListen } @Test + @Config(maxSdk = 37) // The workaround isn't needed from API 38+ public void onAudioDevicesRemoved_fromBackgroundThreadWithoutLooper_notifiesListenerOnLooperThread() throws Exception { @@ -115,9 +113,7 @@ public void onAudioDevicesAdded_fromBackgroundThreadWithoutLooper_notifiesListen } @Test - public void onAudioDevicesAdded_onNonWorkaroundDevice_notifiesListenerOnCallingLooperlessThread() - throws Exception { - ShadowBuild.setModel("OtherModel"); + public void onAudioDevicesAdded_onCorrectLooperThread_doesntRePost() throws Exception { TrackingListener listener = new TrackingListener(); AudioCapabilitiesReceiver audioCapabilitiesReceiver = new AudioCapabilitiesReceiver( @@ -128,16 +124,11 @@ public void onAudioDevicesAdded_onNonWorkaroundDevice_notifiesListenerOnCallingL AudioCapabilities unused = audioCapabilitiesReceiver.register(); audioCapabilitiesReceiver.overrideCapabilities(OVERRIDDEN_AUDIO_CAPABILITIES); - wrongThreadExecutor - .submit( - () -> - audioCapabilitiesReceiver.audioDeviceCallback.onAudioDevicesAdded( - new AudioDeviceInfo[0])) - .get(); - shadowOf(Looper.getMainLooper()).idle(); + audioCapabilitiesReceiver.audioDeviceCallback.onAudioDevicesAdded(new AudioDeviceInfo[0]); + // Assert the listener is invoked immediately without needing to idle the main looper. assertThat(listener.invocationCount.get()).isEqualTo(3); - assertThat(listener.looperlessThreads).hasSize(1); + assertThat(listener.looperlessThreads).hasSize(0); } private static final class TrackingListener implements AudioCapabilitiesReceiver.Listener { From 518ccdf8a62ec80c1893b6eef9b5667f581b551c Mon Sep 17 00:00:00 2001 From: Ian Baker Date: Thu, 24 Sep 2026 17:06:02 +0100 Subject: [PATCH 8/8] Small test fixes from internal review --- .../audio/AudioCapabilitiesReceiverWrongThreadTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 897cb64c273..d9934774c90 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 @@ -70,7 +70,7 @@ public void onAudioDevicesAdded_fromBackgroundThreadWithoutLooper_notifiesListen listener, new AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).build(), /* routedDevice= */ null); - AudioCapabilities unused = audioCapabilitiesReceiver.register(); + AudioCapabilities _ = audioCapabilitiesReceiver.register(); audioCapabilitiesReceiver.overrideCapabilities(OVERRIDDEN_AUDIO_CAPABILITIES); wrongThreadExecutor @@ -97,7 +97,7 @@ public void onAudioDevicesAdded_fromBackgroundThreadWithoutLooper_notifiesListen listener, new AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).build(), /* routedDevice= */ null); - AudioCapabilities unused = audioCapabilitiesReceiver.register(); + AudioCapabilities _ = audioCapabilitiesReceiver.register(); audioCapabilitiesReceiver.overrideCapabilities(OVERRIDDEN_AUDIO_CAPABILITIES); wrongThreadExecutor @@ -121,14 +121,14 @@ public void onAudioDevicesAdded_onCorrectLooperThread_doesntRePost() throws Exce listener, new AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).build(), /* routedDevice= */ null); - AudioCapabilities unused = audioCapabilitiesReceiver.register(); + AudioCapabilities _ = audioCapabilitiesReceiver.register(); audioCapabilitiesReceiver.overrideCapabilities(OVERRIDDEN_AUDIO_CAPABILITIES); audioCapabilitiesReceiver.audioDeviceCallback.onAudioDevicesAdded(new AudioDeviceInfo[0]); // Assert the listener is invoked immediately without needing to idle the main looper. assertThat(listener.invocationCount.get()).isEqualTo(3); - assertThat(listener.looperlessThreads).hasSize(0); + assertThat(listener.looperlessThreads).isEmpty(); } private static final class TrackingListener implements AudioCapabilitiesReceiver.Listener {