diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioCapabilitiesEndToEndTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioCapabilitiesEndToEndTest.java new file mode 100644 index 0000000000..fc4d37cae5 --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioCapabilitiesEndToEndTest.java @@ -0,0 +1,583 @@ +/* + * Copyright 2024 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 android.media.AudioFormat.CHANNEL_OUT_5POINT1; +import static android.media.AudioFormat.CHANNEL_OUT_STEREO; +import static android.media.AudioFormat.ENCODING_AC3; +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.run; +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.robolectric.Shadows.shadowOf; + +import android.app.UiModeManager; +import android.content.Context; +import android.content.res.Configuration; +import android.media.AudioAttributes; +import android.media.AudioDeviceInfo; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioProfile; +import android.media.MediaCodec; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.Player; +import androidx.media3.common.Tracks; +import androidx.media3.common.Tracks.Group; +import androidx.media3.common.util.Util; +import androidx.media3.exoplayer.DecoderCounters; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.Renderer; +import androidx.media3.exoplayer.RenderersFactory; +import androidx.media3.exoplayer.analytics.AnalyticsListener; +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector; +import androidx.media3.exoplayer.mediacodec.SynchronousMediaCodecAdapter; +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector.Parameters; +import androidx.media3.test.utils.FakeClock; +import androidx.media3.test.utils.robolectric.ShadowMediaCodecConfig; +import androidx.media3.test.utils.robolectric.TestPlayerRunHelper; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.AudioDeviceInfoBuilder; +import org.robolectric.shadows.AudioProfileBuilder; +import org.robolectric.shadows.ShadowAudioTrack; +import org.robolectric.shadows.ShadowUIModeManager; + +/** End to end playback test for audio capabilities. */ +@RunWith(AndroidJUnit4.class) +public class AudioCapabilitiesEndToEndTest { + + private Context applicationContext; + private AudioManager audioManager; + private Parameters defaultParameters; + @Nullable private AudioDeviceInfo directPlaybackDevice; + private List selectedTracks; + private List analyticsListenerReceivedCallbacks; + + @Rule + public ShadowMediaCodecConfig shadowMediaCodecConfig = + ShadowMediaCodecConfig.withNoDefaultSupportedMimeTypes(); + + @Before + public void setUp() { + applicationContext = ApplicationProvider.getApplicationContext(); + audioManager = (AudioManager) applicationContext.getSystemService(Context.AUDIO_SERVICE); + shadowOf(audioManager).setOutputDevices(ImmutableList.of()); + defaultParameters = Parameters.getDefaults(applicationContext); + selectedTracks = new ArrayList<>(); + analyticsListenerReceivedCallbacks = new ArrayList<>(); + } + + @After + public void tearDown() { + clearDirectPlaybackSupport(); + } + + /** + * Tests that ExoPlayer recovers from AudioTrack recoverable error. + * + *

The test starts playing of AC3 audio via audio passthrough (direct playback). Mid-playback, + * {@link android.media.AudioTrack} no longer supports direct playback of the format, emulating a + * TV where the routed audio device moves from TV speakers to a bluetooth headset, and writing to + * the {@link android.media.AudioTrack} returns {@link + * android.media.AudioTrack#ERROR_DEAD_OBJECT}. As a result, the player triggers a new track + * selection and picks the AAC stereo format which is available. + * + *

For {@code API 33+}, the {@link AudioCapabilities} polls the platform with {@link + * AudioManager#getDirectProfilesForAttributes(AudioAttributes)}. And for {@code 29 <= API < 33}, + * the {@link AudioCapabilities} polls the platform with {@link + * android.media.AudioTrack#isDirectPlaybackSupported}. Use the {@link + * SynchronousMediaCodecAdapter} because {@link MediaCodec} is not fully functional in + * asynchronous mode with Robolectric. + */ + @Test + @Config(minSdk = 29) + public void playAc3WithDirectPlayback_directPlaybackNotSupportMidPlayback_recoversToAac() + throws Exception { + shadowMediaCodecConfig.addSupportedMimeTypes(MimeTypes.AUDIO_AAC); + setupDefaultPcmSupport(); + addDirectPlaybackSupportForAC3(); + setUiModeToTv(); + RenderersFactory renderersFactory = + createRenderersFactory( + // Right after we write the first buffer to audio sink, AudioTrack stops + // supporting the audio format directly. + /* onProcessedOutputBufferRunnable= */ this::clearDirectPlaybackSupport); + ExoPlayer player = + new ExoPlayer.Builder(applicationContext, renderersFactory) + .setClock(new FakeClock(/* isAutoAdvancing= */ true)) + .build(); + player.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample_ac3_aac.mp4")); + player.prepare(); + Player.Listener listener = mock(Player.Listener.class); + player.addListener(listener); + + player.play(); + run(player).ignoringNonFatalErrors().untilState(Player.STATE_ENDED); + player.release(); + + ArgumentCaptor tracks = ArgumentCaptor.forClass(Tracks.class); + verify(listener, times(2)).onTracksChanged(tracks.capture()); + // First track selection picks AC3 and second track selection picks AAC. + Tracks firstTrackSelection = tracks.getAllValues().get(0); + assertThat(firstTrackSelection.getGroups()).hasSize(2); + assertThat(firstTrackSelection.getGroups().get(0).isSelected()).isTrue(); + assertThat(firstTrackSelection.getGroups().get(0).getTrackFormat(0).sampleMimeType) + .isEqualTo(MimeTypes.AUDIO_AC3); + assertThat(firstTrackSelection.getGroups().get(1).isSelected()).isFalse(); + assertThat(firstTrackSelection.getGroups().get(1).getTrackFormat(0).sampleMimeType) + .isEqualTo(MimeTypes.AUDIO_AAC); + Tracks secondTrackSelection = tracks.getAllValues().get(1); + assertThat(secondTrackSelection.getGroups()).hasSize(2); + assertThat(secondTrackSelection.getGroups().get(0).isSelected()).isFalse(); + assertThat(secondTrackSelection.getGroups().get(0).getTrackFormat(0).sampleMimeType) + .isEqualTo(MimeTypes.AUDIO_AC3); + assertThat(secondTrackSelection.getGroups().get(1).isSelected()).isTrue(); + assertThat(secondTrackSelection.getGroups().get(1).getTrackFormat(0).sampleMimeType) + .isEqualTo(MimeTypes.AUDIO_AAC); + } + + /** + * Tests that ExoPlayer recovers from AudioTrack recoverable error. + * + *

The test starts playing of AC3 audio via audio passthrough (direct playback). Mid-playback, + * {@link android.media.AudioTrack} no longer supports direct playback of the format, emulating a + * TV where the routed audio device moves from TV speakers to a bluetooth headset, and writing to + * the {@link android.media.AudioTrack} returns {@link + * android.media.AudioTrack#ERROR_DEAD_OBJECT}. In this test, the device also has an AC3 decoder, + * so after the recovery, the AC3 audio is chosen again. + * + *

For {@code API 33+}, the {@link AudioCapabilities} polls the platform with {@link + * AudioManager#getDirectProfilesForAttributes(AudioAttributes)}. And for {@code 29 <= API < 33}, + * the {@link AudioCapabilities} polls the platform with {@link + * android.media.AudioTrack#isDirectPlaybackSupported}. Use the {@link + * SynchronousMediaCodecAdapter} because {@link MediaCodec} is not fully functional in + * asynchronous mode with Robolectric. + */ + @Test + @Config(minSdk = 29) + public void + playAc3WithDirectPlayback_directPlaybackNotSupportMidPlaybackButDeviceHasAc3Codec_recoversToAc3() + throws Throwable { + shadowMediaCodecConfig.addSupportedMimeTypes(MimeTypes.AUDIO_AAC, MimeTypes.AUDIO_AC3); + setupDefaultPcmSupport(); + addDirectPlaybackSupportForAC3(); + setUiModeToTv(); + RenderersFactory renderersFactory = + createRenderersFactory( + // Right after we write the first buffer to audio sink, AudioTrack stops + // supporting the audio format directly. + /* onProcessedOutputBufferRunnable= */ this::clearDirectPlaybackSupport); + ExoPlayer player = + new ExoPlayer.Builder(applicationContext, renderersFactory) + .setClock(new FakeClock(/* isAutoAdvancing= */ true)) + .build(); + player.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample_ac3_aac.mp4")); + player.prepare(); + player.addAnalyticsListener(createAnalyticsListener()); + + player.play(); + run(player).ignoringNonFatalErrors().untilState(Player.STATE_ENDED); + player.release(); + + // We expect to start playing audio via passthrough and mid-playback switch to a local decoder. + // Hence, the audio renderer is enabled and disabled without an audio decoder initialized, + // indicating direct audio playback. Then the audio renderer is enabled and an audio decoder is + // initialized, indicating local decoding of audio. We cannot verify the order of callbacks with + // Mockito's in-order verification because onAudioEnabled is called twice and we cannot reliably + // distinguish between the two calls with Mockito. + assertThat(analyticsListenerReceivedCallbacks) + .containsExactly( + "onAudioEnabled", "onAudioDisabled", "onAudioEnabled", "onAudioDecoderInitialized") + .inOrder(); + // Verify onTracksChanged was called exactly once and the AC3 track was selected. + ImmutableList groups = Iterables.getOnlyElement(selectedTracks).getGroups(); + assertThat(groups.get(0).isSelected()).isTrue(); + assertThat(groups.get(0).getTrackFormat(0).sampleMimeType).isEqualTo(MimeTypes.AUDIO_AC3); + assertThat(groups.get(1).isSelected()).isFalse(); + } + + /** + * Tests that ExoPlayer switches to direct playback mode after audio capabilities change. + * + *

The test starts with playing AAC stereo audio over a bluetooth headset, and the {@linkplain + * Parameters.Builder#setAllowInvalidateSelectionsOnRendererCapabilitiesChange(boolean) is turned + * on}. Mid-playback, the bluetooth headset is disconnected and the default TV speaker supports + * direct playback for AC3. Then the AC3 audio is selected for direct playback. + * + *

For {@code API 33+}, the {@link AudioCapabilities} polls the platform with {@link + * AudioManager#getDirectProfilesForAttributes(AudioAttributes)}. And for {@code 29 <= API < 33}, + * the {@link AudioCapabilities} polls the platform with {@link + * android.media.AudioTrack#isDirectPlaybackSupported}. Use the {@link + * SynchronousMediaCodecAdapter} because {@link MediaCodec} is not fully functional in + * asynchronous mode with Robolectric. + */ + @Test + @Config(minSdk = 29) + public void playAacWithCodec_directPlaybackSupportMidPlayback_changeToAc3DirectPlayback() + throws Throwable { + final AtomicBoolean directPlaybackSupportAddedReference = new AtomicBoolean(); + + setupDefaultPcmSupport(); + shadowMediaCodecConfig.addSupportedMimeTypes(MimeTypes.AUDIO_AAC); + setUiModeToTv(); + RenderersFactory renderersFactory = + createRenderersFactory( + /* onProcessedOutputBufferRunnable= */ () -> { + // Right after we write the first buffer to audio sink, direct playback support + // is added, and AudioTrack begins to support the audio format directly. + if (!directPlaybackSupportAddedReference.get()) { + addDirectPlaybackSupportForAC3(); + directPlaybackSupportAddedReference.set(true); + } + }); + ExoPlayer player = + new ExoPlayer.Builder(applicationContext, renderersFactory) + .setClock(new FakeClock(/* isAutoAdvancing= */ true)) + .build(); + player.setTrackSelectionParameters( + defaultParameters + .buildUpon() + .setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true) + .build()); + player.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample_ac3_aac.mp4")); + player.prepare(); + Player.Listener listener = mock(Player.Listener.class); + player.addListener(listener); + + player.play(); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + ArgumentCaptor tracks = ArgumentCaptor.forClass(Tracks.class); + verify(listener, times(2)).onTracksChanged(tracks.capture()); + // First track selection picks AAC and second track selection picks AC3. + Tracks firstTrackSelection = tracks.getAllValues().get(0); + assertThat(firstTrackSelection.getGroups()).hasSize(2); + assertThat(firstTrackSelection.getGroups().get(0).isSelected()).isFalse(); + assertThat(firstTrackSelection.getGroups().get(0).getTrackFormat(0).sampleMimeType) + .isEqualTo(MimeTypes.AUDIO_AC3); + assertThat(firstTrackSelection.getGroups().get(1).isSelected()).isTrue(); + assertThat(firstTrackSelection.getGroups().get(1).getTrackFormat(0).sampleMimeType) + .isEqualTo(MimeTypes.AUDIO_AAC); + Tracks secondTrackSelection = tracks.getAllValues().get(1); + assertThat(secondTrackSelection.getGroups()).hasSize(2); + assertThat(secondTrackSelection.getGroups().get(0).isSelected()).isTrue(); + assertThat(secondTrackSelection.getGroups().get(0).getTrackFormat(0).sampleMimeType) + .isEqualTo(MimeTypes.AUDIO_AC3); + assertThat(secondTrackSelection.getGroups().get(1).isSelected()).isFalse(); + assertThat(secondTrackSelection.getGroups().get(1).getTrackFormat(0).sampleMimeType) + .isEqualTo(MimeTypes.AUDIO_AAC); + } + + /** + * Tests that ExoPlayer switches to direct playback mode after audio capabilities change. + * + *

The test starts with playing over a bluetooth headset, the AC3 audio is decoded to PCM with + * a local decoder, and the {@linkplain + * Parameters.Builder#setAllowInvalidateSelectionsOnRendererCapabilitiesChange(boolean) is turned + * on}. Mid-playback, the bluetooth headset is disconnected and the default TV speaker supports + * direct playback for AC3. Then the AC3 audio is selected again for direct playback. + * + *

For {@code API 33+}, the {@link AudioCapabilities} polls the platform with {@link + * AudioManager#getDirectProfilesForAttributes(AudioAttributes)}. And for {@code 29 <= API < 33}, + * the {@link AudioCapabilities} polls the platform with {@link + * android.media.AudioTrack#isDirectPlaybackSupported}. Use the {@link + * SynchronousMediaCodecAdapter} because {@link MediaCodec} is not fully functional in + * asynchronous mode with Robolectric. + */ + @Test + @Config(minSdk = 29) + public void playAc3WithCodec_directPlaybackSupportMidPlayback_changeToAc3DirectPlayback() + throws Throwable { + final AtomicBoolean directPlaybackSupportAddedReference = new AtomicBoolean(); + + setupDefaultPcmSupport(); + shadowMediaCodecConfig.addSupportedMimeTypes(MimeTypes.AUDIO_AAC, MimeTypes.AUDIO_AC3); + setUiModeToTv(); + RenderersFactory renderersFactory = + createRenderersFactory( + /* onProcessedOutputBufferRunnable= */ () -> { + // Right after we write the first buffer to audio sink, direct playback support + // is added, and AudioTrack begins to support the audio format directly. + if (!directPlaybackSupportAddedReference.get()) { + addDirectPlaybackSupportForAC3(); + directPlaybackSupportAddedReference.set(true); + } + }); + ExoPlayer player = + new ExoPlayer.Builder(applicationContext, renderersFactory) + .setClock(new FakeClock(/* isAutoAdvancing= */ true)) + .build(); + player.setTrackSelectionParameters( + defaultParameters + .buildUpon() + .setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true) + .build()); + player.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample_ac3_aac.mp4")); + player.prepare(); + player.addAnalyticsListener(createAnalyticsListener()); + + player.play(); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + // We expect to start playing audio with a local AC3 decoder and mid-playback switch to the + // passthrough mode. Hence, the audio renderer is enabled and disabled with an audio decoder + // initialized, indicating the local decoding of audio. Then the audio renderer is enabled and + // the audio decoder is released, indicating the direct audio playback. We cannot verify the + // order of callbacks with Mockito's in-order verification because onAudioEnabled is called + // twice and we cannot reliably distinguish between the two calls with Mockito. + assertThat(analyticsListenerReceivedCallbacks) + .containsExactly( + "onAudioEnabled", + "onAudioDecoderInitialized", + "onAudioDisabled", + "onAudioEnabled", + "onAudioDecoderReleased") + .inOrder(); + // Verify onTracksChanged was called exactly once and the AC3 track was selected. + ImmutableList groups = Iterables.getOnlyElement(selectedTracks).getGroups(); + assertThat(groups.get(0).isSelected()).isTrue(); + assertThat(groups.get(0).getTrackFormat(0).sampleMimeType).isEqualTo(MimeTypes.AUDIO_AC3); + assertThat(groups.get(1).isSelected()).isFalse(); + } + + /** + * Tests that ExoPlayer doesn't switch track after audio capabilities change when the {@linkplain + * Parameters.Builder#setAllowInvalidateSelectionsOnRendererCapabilitiesChange(boolean) is turned + * off}. + * + *

The test starts with playing AAC stereo audio over a bluetooth headset, and the {@linkplain + * Parameters.Builder#setAllowInvalidateSelectionsOnRendererCapabilitiesChange(boolean) is turned + * off}. Mid-playback, the bluetooth headset is disconnected and the default TV speaker supports + * direct playback for AC3. The playback remains with AAC track. + * + *

For {@code API 33+}, the {@link AudioCapabilities} polls the platform with {@link + * AudioManager#getDirectProfilesForAttributes(AudioAttributes)}. And for {@code 29 <= API < 33}, + * the {@link AudioCapabilities} polls the platform with {@link + * android.media.AudioTrack#isDirectPlaybackSupported}. Use the {@link + * SynchronousMediaCodecAdapter} because {@link MediaCodec} is not fully functional in + * asynchronous mode with Robolectric. + */ + @Test + @Config(minSdk = 29) + public void playAacWithCodec_rendererCapabilitiesChangedWhenSelectionInvalidationTurnedOff() + throws Throwable { + final AtomicBoolean directPlaybackSupportAddedReference = new AtomicBoolean(); + + shadowMediaCodecConfig.addSupportedMimeTypes(MimeTypes.AUDIO_AAC); + setupDefaultPcmSupport(); + setUiModeToTv(); + RenderersFactory renderersFactory = + createRenderersFactory( + /* onProcessedOutputBufferRunnable= */ () -> { + // Right after we write the first buffer to audio sink, direct playback support + // is added, and AudioTrack begins to support the audio format directly. + if (!directPlaybackSupportAddedReference.get()) { + addDirectPlaybackSupportForAC3(); + directPlaybackSupportAddedReference.set(true); + } + }); + ExoPlayer player = + new ExoPlayer.Builder(applicationContext, renderersFactory) + .setClock(new FakeClock(/* isAutoAdvancing= */ true)) + .build(); + player.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample_ac3_aac.mp4")); + player.prepare(); + Player.Listener listener = mock(Player.Listener.class); + player.addListener(listener); + + player.play(); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + // Verify onTracksChanged was called exactly once and the AAC track was selected. + ArgumentCaptor tracks = ArgumentCaptor.forClass(Tracks.class); + verify(listener).onTracksChanged(tracks.capture()); + Tracks trackSelection = tracks.getAllValues().get(0); + assertThat(trackSelection.getGroups()).hasSize(2); + assertThat(trackSelection.getGroups().get(0).isSelected()).isFalse(); + assertThat(trackSelection.getGroups().get(0).getTrackFormat(0).sampleMimeType) + .isEqualTo(MimeTypes.AUDIO_AC3); + assertThat(trackSelection.getGroups().get(1).isSelected()).isTrue(); + assertThat(trackSelection.getGroups().get(1).getTrackFormat(0).sampleMimeType) + .isEqualTo(MimeTypes.AUDIO_AAC); + } + + private void setUiModeToTv() { + ShadowUIModeManager shadowUiModeManager = + shadowOf( + (UiModeManager) + ApplicationProvider.getApplicationContext() + .getSystemService(Context.UI_MODE_SERVICE)); + shadowUiModeManager.currentModeType = Configuration.UI_MODE_TYPE_TELEVISION; + } + + private void addDirectPlaybackSupportForAC3() { + ShadowAudioTrack.addAllowedNonPcmEncoding(ENCODING_AC3); + // Set direct playback support for the format and attributes that AudioCapabilities use when + // querying the platform. + ShadowAudioTrack.addDirectPlaybackSupport( + new AudioFormat.Builder() + .setEncoding(ENCODING_AC3) + .setSampleRate(AudioCapabilities.DEFAULT_SAMPLE_RATE_HZ) + .setChannelMask(CHANNEL_OUT_STEREO) + .build(), + new android.media.AudioAttributes.Builder() + .setUsage(android.media.AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN) + .setFlags(0) + .build()); + directPlaybackDevice = createDirectPlaybackDevice(ENCODING_AC3, CHANNEL_OUT_5POINT1); + if (Util.SDK_INT >= 33) { + shadowOf(audioManager).addOutputDeviceWithDirectProfiles(checkNotNull(directPlaybackDevice)); + } + shadowOf(audioManager) + .addOutputDevice( + checkNotNull(directPlaybackDevice), /* notifyAudioDeviceCallbacks= */ true); + } + + private void clearDirectPlaybackSupport() { + ShadowAudioTrack.clearAllowedNonPcmEncodings(); + ShadowAudioTrack.clearDirectPlaybackSupportedFormats(); + if (directPlaybackDevice != null) { + if (Util.SDK_INT >= 33) { + shadowOf(audioManager).removeOutputDeviceWithDirectProfiles(directPlaybackDevice); + } + shadowOf(audioManager) + .removeOutputDevice(directPlaybackDevice, /* notifyAudioDeviceCallbacks= */ true); + directPlaybackDevice = null; + } + } + + private void setupDefaultPcmSupport() { + AudioDeviceInfoBuilder defaultDevice = + AudioDeviceInfoBuilder.newBuilder().setType(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + if (Util.SDK_INT >= 33) { + defaultDevice.setProfiles(ImmutableList.of(createPcmProfile())); + shadowOf(audioManager).addOutputDeviceWithDirectProfiles(defaultDevice.build()); + } else { + shadowOf(audioManager) + .addOutputDevice(defaultDevice.build(), /* notifyAudioDeviceCallbacks= */ true); + } + } + + @SuppressWarnings("UseSdkSuppress") // https://issuetracker.google.com/382253664 + @RequiresApi(33) + private static AudioProfile createPcmProfile() { + return AudioProfileBuilder.newBuilder() + .setFormat(AudioFormat.ENCODING_PCM_16BIT) + .setSamplingRates(new int[] {48_000}) + .setChannelMasks(new int[] {AudioFormat.CHANNEL_OUT_STEREO}) + .setChannelIndexMasks(new int[] {}) + .setEncapsulationType(AudioProfile.AUDIO_ENCAPSULATION_TYPE_NONE) + .build(); + } + + private static AudioDeviceInfo createDirectPlaybackDevice(int encoding, int channelMask) { + AudioDeviceInfoBuilder directPlaybackDevice = + AudioDeviceInfoBuilder.newBuilder().setType(AudioDeviceInfo.TYPE_HDMI); + if (Util.SDK_INT >= 33) { + ImmutableList expectedProfiles = + ImmutableList.of( + AudioProfileBuilder.newBuilder() + .setFormat(encoding) + .setSamplingRates(new int[] {48_000}) + .setChannelMasks(new int[] {channelMask}) + .setChannelIndexMasks(new int[] {}) + .setEncapsulationType(AudioProfile.AUDIO_ENCAPSULATION_TYPE_NONE) + .build(), + createPcmProfile()); + directPlaybackDevice.setProfiles(expectedProfiles); + } + return directPlaybackDevice.build(); + } + + private RenderersFactory createRenderersFactory(Runnable onProcessedOutputBufferRunnable) { + return (eventHandler, + unusedVideoRendererEventListener, + audioRendererEventListener, + unusedTextRendererOutput, + unusedMetadataRendererOutput) -> + new Renderer[] { + new MediaCodecAudioRenderer( + applicationContext, + new SynchronousMediaCodecAdapter.Factory(), + MediaCodecSelector.DEFAULT, + /* enableDecoderFallback= */ false, + eventHandler, + audioRendererEventListener, + new DefaultAudioSink.Builder(applicationContext).build()) { + @Override + protected void onProcessedOutputBuffer(long presentationTimeUs) { + onProcessedOutputBufferRunnable.run(); + super.onProcessedOutputBuffer(presentationTimeUs); + } + } + }; + } + + private AnalyticsListener createAnalyticsListener() { + return new AnalyticsListener() { + @Override + public void onTracksChanged(EventTime eventTime, Tracks tracks) { + selectedTracks.add(tracks); + } + + @Override + public void onAudioEnabled(EventTime eventTime, DecoderCounters decoderCounters) { + analyticsListenerReceivedCallbacks.add("onAudioEnabled"); + } + + @Override + public void onAudioDisabled(EventTime eventTime, DecoderCounters decoderCounters) { + analyticsListenerReceivedCallbacks.add("onAudioDisabled"); + } + + @Override + public void onAudioDecoderInitialized( + EventTime eventTime, + String decoderName, + long initializedTimestampMs, + long initializationDurationMs) { + analyticsListenerReceivedCallbacks.add("onAudioDecoderInitialized"); + } + + @Override + public void onAudioDecoderReleased(EventTime eventTime, String decoderName) { + analyticsListenerReceivedCallbacks.add("onAudioDecoderReleased"); + } + }; + } +} diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioCapabilitiesTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioCapabilitiesTest.java new file mode 100644 index 0000000000..e5cbef4a64 --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioCapabilitiesTest.java @@ -0,0 +1,529 @@ +/* + * Copyright 2024 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 android.media.AudioFormat.CHANNEL_OUT_5POINT1; +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.exoplayer.audio.AudioCapabilities.ALL_SURROUND_ENCODINGS_AND_MAX_CHANNELS; +import static androidx.media3.exoplayer.audio.AudioCapabilities.getCapabilities; +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.Shadows.shadowOf; + +import android.app.UiModeManager; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.media.AudioDeviceInfo; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioProfile; +import android.provider.Settings.Global; +import android.util.Pair; +import androidx.annotation.RequiresApi; +import androidx.media3.common.AudioAttributes; +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.util.Util; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; +import java.util.ArrayList; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.AudioDeviceInfoBuilder; +import org.robolectric.shadows.AudioProfileBuilder; +import org.robolectric.shadows.ShadowAudioTrack; +import org.robolectric.shadows.ShadowBuild; +import org.robolectric.shadows.ShadowUIModeManager; + +/** Unit tests for {@link AudioCapabilities}. */ +@RunWith(AndroidJUnit4.class) +public class AudioCapabilitiesTest { + + private AudioManager audioManager; + private UiModeManager uiModeManager; + + @Before + public void setUp() { + audioManager = + (AudioManager) + ApplicationProvider.getApplicationContext().getSystemService(Context.AUDIO_SERVICE); + shadowOf(audioManager).setOutputDevices(ImmutableList.of()); + uiModeManager = + (UiModeManager) + ApplicationProvider.getApplicationContext().getSystemService(Context.UI_MODE_SERVICE); + } + + @Test + @Config(minSdk = 33) + public void getCapabilities_returnsCapabilitiesFromDirectProfiles_onTvV33() { + // Set UI mode to TV. + ShadowUIModeManager shadowUiModeManager = + shadowOf( + (UiModeManager) + ApplicationProvider.getApplicationContext() + .getSystemService(Context.UI_MODE_SERVICE)); + shadowUiModeManager.currentModeType = Configuration.UI_MODE_TYPE_TELEVISION; + int[] channelMasks = + new int[] { + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.CHANNEL_OUT_STEREO, + AudioFormat.CHANNEL_OUT_STEREO | AudioFormat.CHANNEL_OUT_FRONT_CENTER, + AudioFormat.CHANNEL_OUT_QUAD, + AudioFormat.CHANNEL_OUT_QUAD | AudioFormat.CHANNEL_OUT_FRONT_CENTER, + AudioFormat.CHANNEL_OUT_5POINT1 + }; + ImmutableList expectedProfiles = + ImmutableList.of( + AudioProfileBuilder.newBuilder() + .setFormat(AudioFormat.ENCODING_DTS) + .setSamplingRates(new int[] {44_100, 48_000}) + .setChannelMasks(channelMasks) + .setChannelIndexMasks(new int[] {}) + .setEncapsulationType(AudioProfile.AUDIO_ENCAPSULATION_TYPE_NONE) + .build(), + AudioProfileBuilder.newBuilder() + .setFormat(AudioFormat.ENCODING_PCM_16BIT) + .setSamplingRates(new int[] {48_000}) + .setChannelMasks(new int[] {AudioFormat.CHANNEL_OUT_STEREO}) + .setChannelIndexMasks(new int[] {}) + .setEncapsulationType(AudioProfile.AUDIO_ENCAPSULATION_TYPE_NONE) + .build()); + AudioDeviceInfo device = + AudioDeviceInfoBuilder.newBuilder() + .setType(AudioDeviceInfo.TYPE_HDMI) + .setProfiles(expectedProfiles) + .build(); + shadowOf(audioManager).addOutputDeviceWithDirectProfiles(device); + + AudioCapabilities audioCapabilities = + AudioCapabilities.getCapabilities( + ApplicationProvider.getApplicationContext(), + AudioAttributes.DEFAULT, + /* routedDevice= */ null); + + assertThat(audioCapabilities.supportsEncoding(C.ENCODING_PCM_16BIT)).isTrue(); + for (int encoding : ALL_SURROUND_ENCODINGS_AND_MAX_CHANNELS.keySet()) { + if (encoding == AudioFormat.ENCODING_DTS) { + assertThat(audioCapabilities.supportsEncoding(encoding)).isTrue(); + } else { + // Should not support all the other encodings. + assertThat(audioCapabilities.supportsEncoding(encoding)).isFalse(); + } + } + // Should not support the format whose channel count is not reported in the profile. + assertThat( + audioCapabilities.isPassthroughPlaybackSupported( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_DTS) + .setChannelCount(8) + .build(), + AudioAttributes.DEFAULT)) + .isFalse(); + } + + /** {@link AudioDeviceInfo#TYPE_BLUETOOTH_A2DP} is only supported from API 23. */ + @Test + @Config(minSdk = 23) + public void getCapabilities_withBluetoothA2dpAndHdmiConnectedApi23_returnsDefaultCapabilities() { + setOutputDevices(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_HDMI); + configureHdmiConnection(/* maxChannelCount= */ 6, /* encodings...= */ AudioFormat.ENCODING_AC3); + + AudioDeviceInfo[] audioDeviceInfos = + shadowOf(audioManager).getDevices(AudioManager.GET_DEVICES_OUTPUTS); + AudioCapabilities audioCapabilities = + AudioCapabilities.getCapabilities( + ApplicationProvider.getApplicationContext(), + AudioAttributes.DEFAULT, + /* routedDevice= */ null); + + assertThat(getDeviceTypes(audioDeviceInfos)) + .containsAtLeast(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_HDMI); + assertThat(audioCapabilities).isEqualTo(AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES); + } + + /** {@link AudioDeviceInfo#TYPE_BLE_HEADSET} is only supported from API 31. */ + @Test + @Config(minSdk = 31) + public void + getCapabilities_withBluetoothHeadsetAndHmdiConnectedApi31_returnsDefaultCapabilities() { + setOutputDevices(AudioDeviceInfo.TYPE_BLE_HEADSET, AudioDeviceInfo.TYPE_HDMI); + configureHdmiConnection(/* maxChannelCount= */ 6, /* encodings...= */ AudioFormat.ENCODING_AC3); + + AudioDeviceInfo[] audioDeviceInfos = + shadowOf(audioManager).getDevices(AudioManager.GET_DEVICES_OUTPUTS); + AudioCapabilities audioCapabilities = + AudioCapabilities.getCapabilities( + ApplicationProvider.getApplicationContext(), + AudioAttributes.DEFAULT, + /* routedDevice= */ null); + + assertThat(getDeviceTypes(audioDeviceInfos)) + .containsAtLeast(AudioDeviceInfo.TYPE_BLE_HEADSET, AudioDeviceInfo.TYPE_HDMI); + assertThat(audioCapabilities).isEqualTo(AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES); + } + + /** {@link AudioDeviceInfo#TYPE_BLE_BROADCAST} is only supported from API 33. */ + @Test + @Config(minSdk = 33) + public void + getCapabilities_withBluetoothBroadcastAndHdmiConnectedApi33_returnsDefaultCapabilities() { + setOutputDevices(AudioDeviceInfo.TYPE_BLE_BROADCAST, AudioDeviceInfo.TYPE_HDMI); + configureHdmiConnection(/* maxChannelCount= */ 6, /* encodings...= */ AudioFormat.ENCODING_AC3); + + AudioDeviceInfo[] audioDeviceInfos = + shadowOf(audioManager).getDevices(AudioManager.GET_DEVICES_OUTPUTS); + AudioCapabilities audioCapabilities = + AudioCapabilities.getCapabilities( + ApplicationProvider.getApplicationContext(), + AudioAttributes.DEFAULT, + /* routedDevice= */ null); + + assertThat(getDeviceTypes(audioDeviceInfos)) + .containsAtLeast(AudioDeviceInfo.TYPE_BLE_BROADCAST, AudioDeviceInfo.TYPE_HDMI); + assertThat(audioCapabilities).isEqualTo(AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES); + } + + @Test + public void getCapabilities_noBluetoothButHdmiConnected_returnsHdmiCapabilities() { + setOutputDevices(AudioDeviceInfo.TYPE_HDMI); + configureHdmiConnection( + /* maxChannelCount= */ 6, + /* encodings...= */ AudioFormat.ENCODING_AC3, + AudioFormat.ENCODING_DTS, + AudioFormat.ENCODING_E_AC3); + + AudioDeviceInfo[] audioDeviceInfos = + shadowOf(audioManager).getDevices(AudioManager.GET_DEVICES_OUTPUTS); + AudioCapabilities audioCapabilities = + AudioCapabilities.getCapabilities( + ApplicationProvider.getApplicationContext(), + AudioAttributes.DEFAULT, + /* routedDevice= */ null); + + assertThat(getDeviceTypes(audioDeviceInfos)).contains(AudioDeviceInfo.TYPE_HDMI); + assertThat(getDeviceTypes(audioDeviceInfos)) + .doesNotContain(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP); + assertThat(audioCapabilities) + .isEqualTo( + new AudioCapabilities( + new int[] { + AudioFormat.ENCODING_PCM_16BIT, + AudioFormat.ENCODING_AC3, + AudioFormat.ENCODING_DTS, + AudioFormat.ENCODING_E_AC3 + }, + /* maxChannelCount= */ 6)); + } + + @Config(maxSdk = 32) // Fallback test for APIs before 33 + @Test + public void + getCapabilities_noBluetoothButGlobalSurroundSettingOnAmazon_returnsExternalSurroundCapabilities() { + Global.putInt( + ApplicationProvider.getApplicationContext().getContentResolver(), + "external_surround_sound_enabled", + 1); + ShadowBuild.setManufacturer("Amazon"); + + AudioCapabilities audioCapabilities = + AudioCapabilities.getCapabilities( + ApplicationProvider.getApplicationContext(), + AudioAttributes.DEFAULT, + /* routedDevice= */ null); + + assertThat(audioCapabilities.supportsEncoding(C.ENCODING_PCM_16BIT)).isTrue(); + assertThat(audioCapabilities.supportsEncoding(C.ENCODING_AC3)).isTrue(); + assertThat(audioCapabilities.supportsEncoding(C.ENCODING_E_AC3)).isTrue(); + } + + // Fallback test for APIs before 33, TYPE_HDMI is only supported from API 23 + @Config(minSdk = 23, maxSdk = 32) + @Test + public void + getCapabilities_noBluetoothButGlobalSurroundSettingForced_returnsExternalSurroundCapabilitiesAndIgnoresHdmi() { + setOutputDevices(AudioDeviceInfo.TYPE_HDMI); + configureHdmiConnection(/* maxChannelCount= */ 6, /* encodings...= */ AudioFormat.ENCODING_DTS); + Global.putInt( + ApplicationProvider.getApplicationContext().getContentResolver(), + "use_external_surround_sound_flag", + 1); + + AudioCapabilities audioCapabilitiesWithoutFlag = + AudioCapabilities.getCapabilities( + ApplicationProvider.getApplicationContext(), + AudioAttributes.DEFAULT, + /* routedDevice= */ null); + Global.putInt( + ApplicationProvider.getApplicationContext().getContentResolver(), + "external_surround_sound_enabled", + 1); + AudioCapabilities audioCapabilitiesWithFlag = + AudioCapabilities.getCapabilities( + ApplicationProvider.getApplicationContext(), + AudioAttributes.DEFAULT, + /* routedDevice= */ null); + + assertThat(audioCapabilitiesWithoutFlag.supportsEncoding(C.ENCODING_PCM_16BIT)).isTrue(); + assertThat(audioCapabilitiesWithoutFlag.supportsEncoding(C.ENCODING_AC3)).isFalse(); + assertThat(audioCapabilitiesWithoutFlag.supportsEncoding(C.ENCODING_E_AC3)).isFalse(); + assertThat(audioCapabilitiesWithoutFlag.supportsEncoding(C.ENCODING_DTS)).isFalse(); + assertThat(audioCapabilitiesWithFlag.supportsEncoding(C.ENCODING_PCM_16BIT)).isTrue(); + assertThat(audioCapabilitiesWithFlag.supportsEncoding(C.ENCODING_AC3)).isTrue(); + assertThat(audioCapabilitiesWithFlag.supportsEncoding(C.ENCODING_E_AC3)).isTrue(); + assertThat(audioCapabilitiesWithFlag.supportsEncoding(C.ENCODING_DTS)).isFalse(); + } + + @Test + @Config(minSdk = 23) // TYPE_BLUETOOTH_A2DP detection is supported from API 23. + public void + getCapabilities_withBluetoothA2dpConnectedAndHdmiAsRoutedDeviceHintApi23_returnsHdmiCapabilities() { + setOutputDevices(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_HDMI); + configureHdmiConnection( + /* maxChannelCount= */ 10, + /* encodings...= */ AudioFormat.ENCODING_AC3, + AudioFormat.ENCODING_DTS, + AudioFormat.ENCODING_E_AC3_JOC); + + AudioDeviceInfo[] audioDeviceInfos = + shadowOf(audioManager).getDevices(AudioManager.GET_DEVICES_OUTPUTS); + AudioDeviceInfo routedDevice = null; + for (AudioDeviceInfo deviceInfo : audioDeviceInfos) { + if (deviceInfo.getType() == AudioDeviceInfo.TYPE_HDMI) { + routedDevice = deviceInfo; + } + } + AudioCapabilities audioCapabilities = + AudioCapabilities.getCapabilities( + ApplicationProvider.getApplicationContext(), AudioAttributes.DEFAULT, routedDevice); + + assertThat(routedDevice).isNotNull(); + assertThat(getDeviceTypes(audioDeviceInfos)) + .containsAtLeast(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_HDMI); + assertThat(audioCapabilities) + .isEqualTo( + new AudioCapabilities( + new int[] { + AudioFormat.ENCODING_PCM_16BIT, + AudioFormat.ENCODING_AC3, + AudioFormat.ENCODING_DTS, + AudioFormat.ENCODING_E_AC3_JOC + }, + /* maxChannelCount= */ 10)); + } + + @Test + @Config(minSdk = 33) // getAudioDevicesForAttributes only works from API33 + public void + getCapabilities_withBluetoothA2dpAndHdmiConnectedAndHdmiAsDefaultRoutedDeviceApi33_returnsHdmiCapabilities() { + setOutputDevices(AudioDeviceInfo.TYPE_HDMI, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP); + setDefaultRoutedDevice(AudioAttributes.DEFAULT, AudioDeviceInfo.TYPE_HDMI); + configureHdmiConnection( + /* maxChannelCount= */ 10, + /* encodings...= */ AudioFormat.ENCODING_AC3, + AudioFormat.ENCODING_DTS, + AudioFormat.ENCODING_E_AC3_JOC); + + AudioDeviceInfo[] audioDeviceInfos = + shadowOf(audioManager).getDevices(AudioManager.GET_DEVICES_OUTPUTS); + AudioCapabilities audioCapabilities = + AudioCapabilities.getCapabilities( + ApplicationProvider.getApplicationContext(), + AudioAttributes.DEFAULT, + /* routedDevice= */ null); + + assertThat(getDeviceTypes(audioDeviceInfos)) + .containsAtLeast(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_HDMI); + assertThat(audioCapabilities) + .isEqualTo( + new AudioCapabilities( + new int[] { + AudioFormat.ENCODING_PCM_16BIT, + AudioFormat.ENCODING_AC3, + AudioFormat.ENCODING_DTS, + AudioFormat.ENCODING_E_AC3_JOC + }, + /* maxChannelCount= */ 10)); + } + + @Test + public void getCapabilities_noExternalOutputs_notTvNorAutomotive_returnsDefaultCapabilities() { + AudioCapabilities audioCapabilities = + AudioCapabilities.getCapabilities( + ApplicationProvider.getApplicationContext(), + AudioAttributes.DEFAULT, + /* routedDevice= */ null); + + assertThat(audioCapabilities).isEqualTo(AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES); + } + + @Config(minSdk = 29) + @Test + public void + getEncodingAndChannelConfigForPassthrough_forEAc3JocAndSingleSupportedConfig_returnsCorrectEncodingAndChannelConfig() { + // Set UI mode to TV. + shadowOf(uiModeManager).currentModeType = Configuration.UI_MODE_TYPE_TELEVISION; + Format format = + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_E_AC3_JOC) + .setSampleRate(48_000) + .build(); + AudioAttributes directPlaybackAudioAttributes = + new AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(); + addDirectPlaybackSupport( + AudioFormat.ENCODING_E_AC3_JOC, CHANNEL_OUT_5POINT1, directPlaybackAudioAttributes); + AudioCapabilities audioCapabilities = + getCapabilities( + ApplicationProvider.getApplicationContext(), + directPlaybackAudioAttributes, + /* routedDevice= */ null); + + Pair encodingAndChannelConfig = + audioCapabilities.getEncodingAndChannelConfigForPassthrough( + format, directPlaybackAudioAttributes); + + assertThat(encodingAndChannelConfig.first).isEqualTo(AudioFormat.ENCODING_E_AC3_JOC); + assertThat(encodingAndChannelConfig.second).isEqualTo(AudioFormat.CHANNEL_OUT_5POINT1); + } + + // TODO: b/320191198 - Disable the test for API 33, as the + // ShadowAudioManager.getDirectProfilesForAttributes(AudioAttributes) hasn't really considered + // the AudioAttributes yet. + @Config(minSdk = 29, maxSdk = 32) + @Test + public void + getEncodingAndChannelConfigForPassthrough_forDifferentAudioAttributes_returnsUnsupported() { + // Set UI mode to TV. + shadowOf(uiModeManager).currentModeType = Configuration.UI_MODE_TYPE_TELEVISION; + Format format = + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_E_AC3_JOC) + .setSampleRate(48_000) + .build(); + AudioAttributes directPlaybackAudioAttributes = + new AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(); + addDirectPlaybackSupport( + AudioFormat.ENCODING_E_AC3_JOC, CHANNEL_OUT_5POINT1, directPlaybackAudioAttributes); + AudioCapabilities audioCapabilities = + getCapabilities( + ApplicationProvider.getApplicationContext(), + directPlaybackAudioAttributes, + /* routedDevice= */ null); + AudioAttributes audioAttributes = + new AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build(); + + Pair encodingAndChannelConfig = + audioCapabilities.getEncodingAndChannelConfigForPassthrough(format, audioAttributes); + + assertThat(encodingAndChannelConfig).isNull(); + } + + /** + * Sets all the available output devices and uses the first as the default routed device for the + * given {@link AudioAttributes} + */ + private void setOutputDevices(int... types) { + ImmutableList.Builder audioDeviceInfos = ImmutableList.builder(); + for (int type : types) { + audioDeviceInfos.add(AudioDeviceInfoBuilder.newBuilder().setType(type).build()); + } + shadowOf(audioManager).setOutputDevices(audioDeviceInfos.build()); + } + + @SuppressWarnings("UseSdkSuppress") // https://issuetracker.google.com/382253664 + @RequiresApi(33) + private void setDefaultRoutedDevice(AudioAttributes audioAttributes, int type) { + shadowOf(audioManager) + .setAudioDevicesForAttributes( + audioAttributes.getAudioAttributesV21().audioAttributes, + ImmutableList.of(AudioDeviceInfoBuilder.newBuilder().setType(type).build())); + } + + @SuppressWarnings("UseSdkSuppress") // https://issuetracker.google.com/382253664 + @RequiresApi(23) + private void addDirectPlaybackSupport( + int encoding, int channelMask, AudioAttributes audioAttributes) { + ShadowAudioTrack.addAllowedNonPcmEncoding(AudioFormat.ENCODING_E_AC3_JOC); + // We have to add a support for STEREO channel mask as + // Api29.getDirectPlaybackSupportedEncodings in AudioCapabilities uses CHANNEL_OUT_STEREO + // to query the support from the platform. + ShadowAudioTrack.addDirectPlaybackSupport( + new AudioFormat.Builder() + .setEncoding(encoding) + .setSampleRate(48_000) + .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO) + .build(), + audioAttributes.getAudioAttributesV21().audioAttributes); + ShadowAudioTrack.addDirectPlaybackSupport( + new AudioFormat.Builder() + .setEncoding(encoding) + .setSampleRate(48_000) + .setChannelMask(channelMask) + .build(), + audioAttributes.getAudioAttributesV21().audioAttributes); + AudioDeviceInfoBuilder deviceInfoBuilder = + AudioDeviceInfoBuilder.newBuilder().setType(AudioDeviceInfo.TYPE_HDMI); + if (Util.SDK_INT >= 33) { + ImmutableList expectedProfiles = + ImmutableList.of( + AudioProfileBuilder.newBuilder() + .setFormat(encoding) + .setSamplingRates(new int[] {48_000}) + .setChannelMasks(new int[] {channelMask}) + .setChannelIndexMasks(new int[] {}) + .setEncapsulationType(AudioProfile.AUDIO_ENCAPSULATION_TYPE_NONE) + .build()); + deviceInfoBuilder.setProfiles(expectedProfiles); + } + AudioDeviceInfo directPlaybackDevice = deviceInfoBuilder.build(); + shadowOf(audioManager) + .addOutputDevice(directPlaybackDevice, /* notifyAudioDeviceCallbacks= */ true); + shadowOf(audioManager).addOutputDeviceWithDirectProfiles(checkNotNull(directPlaybackDevice)); + } + + // Adding the permission to the test AndroidManifest.xml doesn't work to appease lint. + @SuppressWarnings({"StickyBroadcast", "MissingPermission"}) + private void configureHdmiConnection(int maxChannelCount, int... encodings) { + Intent intent = new Intent(AudioManager.ACTION_HDMI_AUDIO_PLUG); + intent.putExtra(AudioManager.EXTRA_AUDIO_PLUG_STATE, 1); + intent.putExtra(AudioManager.EXTRA_ENCODINGS, encodings); + intent.putExtra(AudioManager.EXTRA_MAX_CHANNEL_COUNT, maxChannelCount); + ApplicationProvider.getApplicationContext().sendStickyBroadcast(intent); + } + + @SuppressWarnings("UseSdkSuppress") // https://issuetracker.google.com/382253664 + @RequiresApi(23) + private List getDeviceTypes(AudioDeviceInfo[] audioDeviceInfos) { + List deviceTypes = new ArrayList<>(); + for (AudioDeviceInfo audioDeviceInfo : audioDeviceInfos) { + deviceTypes.add(audioDeviceInfo.getType()); + } + return deviceTypes; + } +} diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioOffloadEndToEndTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioOffloadEndToEndTest.java new file mode 100644 index 0000000000..48afe2663e --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/AudioOffloadEndToEndTest.java @@ -0,0 +1,121 @@ +/* + * Copyright 2024 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 androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED; +import static androidx.test.core.app.ApplicationProvider.getApplicationContext; +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioFormat; +import android.media.AudioManager; +import androidx.media3.common.MediaItem; +import androidx.media3.common.Player; +import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences; +import androidx.media3.exoplayer.DefaultRenderersFactory; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.ExoPlayer.AudioOffloadListener; +import androidx.media3.test.utils.FakeClock; +import androidx.media3.test.utils.robolectric.ShadowMediaCodecConfig; +import androidx.media3.test.utils.robolectric.TestPlayerRunHelper; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowAudioSystem; + +@RunWith(AndroidJUnit4.class) +public class AudioOffloadEndToEndTest { + private static final AudioFormat AUDIO_FORMAT = + new AudioFormat.Builder() + .setSampleRate(48_000) + .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO) + .setEncoding(AudioFormat.ENCODING_OPUS) + .build(); + private static final AudioAttributes AUDIO_ATTRIBUTES = + new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN) + .setUsage(AudioAttributes.USAGE_MEDIA) + .setAllowedCapturePolicy(AudioAttributes.ALLOW_CAPTURE_BY_ALL) + .build(); + private static final String OPUS_FILE_URI = "asset:///media/ogg/bear.opus"; + + private final Context applicationContext = getApplicationContext(); + + @Rule + public ShadowMediaCodecConfig mediaCodecConfig = + ShadowMediaCodecConfig.forAllSupportedMimeTypes(); + + @Before + public void setup() { + ShadowAudioSystem.setOffloadSupported(AUDIO_FORMAT, AUDIO_ATTRIBUTES, true); + ShadowAudioSystem.setOffloadPlaybackSupport( + AUDIO_FORMAT, AUDIO_ATTRIBUTES, AudioManager.PLAYBACK_OFFLOAD_SUPPORTED); + ShadowAudioSystem.setDirectPlaybackSupport( + AUDIO_FORMAT, AUDIO_ATTRIBUTES, AudioManager.DIRECT_PLAYBACK_OFFLOAD_SUPPORTED); + } + + @Test + @Config(minSdk = 30) + public void testOffloadPlayback_offloadEnabledAndPlayToEnd() throws Exception { + AtomicBoolean isOffloadModeSet = new AtomicBoolean(false); + DefaultRenderersFactory renderersFactory = + new DefaultRenderersFactory(applicationContext) { + @Override + protected AudioSink buildAudioSink( + Context context, boolean enableFloatOutput, boolean enableAudioTrackPlaybackParams) { + AudioOffloadListener audioOffloadListener = + new AudioOffloadListener() { + @Override + public void onOffloadedPlayback(boolean offloadedPlayback) { + isOffloadModeSet.set(offloadedPlayback); + } + }; + return new DefaultAudioSink.Builder(applicationContext) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .setExperimentalAudioOffloadListener(audioOffloadListener) + .build(); + } + }; + ExoPlayer player = + new ExoPlayer.Builder(applicationContext, renderersFactory) + .setClock(new FakeClock(/* isAutoAdvancing= */ true)) + .build(); + player.setTrackSelectionParameters( + player + .getTrackSelectionParameters() + .buildUpon() + .setAudioOffloadPreferences( + new AudioOffloadPreferences.Builder() + .setAudioOffloadMode(AUDIO_OFFLOAD_MODE_ENABLED) + .build()) + .build()); + player.setMediaItem(MediaItem.fromUri(OPUS_FILE_URI)); + player.prepare(); + + player.play(); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + + assertThat(isOffloadModeSet.get()).isTrue(); + + player.release(); + } +} diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/DefaultAudioSinkTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/DefaultAudioSinkTest.java index 9a9f204455..108bd04af0 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/DefaultAudioSinkTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/audio/DefaultAudioSinkTest.java @@ -20,27 +20,49 @@ import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_SUPPORTED_DI import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_SUPPORTED_WITH_TRANSCODING; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; +import static org.robolectric.Shadows.shadowOf; +import android.app.UiModeManager; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.media.AudioDeviceInfo; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioProfile; +import android.os.Looper; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.media3.common.AudioAttributes; import androidx.media3.common.C; import androidx.media3.common.Format; import androidx.media3.common.MimeTypes; import androidx.media3.common.PlaybackParameters; import androidx.media3.common.audio.AudioProcessor; import androidx.media3.common.audio.AudioProcessorChain; +import androidx.media3.common.util.Util; import androidx.media3.exoplayer.audio.DefaultAudioSink.DefaultAudioProcessorChain; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.Arrays; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.annotation.Config; +import org.robolectric.shadows.AudioDeviceInfoBuilder; +import org.robolectric.shadows.AudioProfileBuilder; +import org.robolectric.shadows.ShadowAudioManager; +import org.robolectric.shadows.ShadowAudioTrack; +import org.robolectric.shadows.ShadowLooper; import org.robolectric.shadows.ShadowSystemClock; +import org.robolectric.shadows.ShadowUIModeManager; /** Unit tests for {@link DefaultAudioSink}. */ @RunWith(AndroidJUnit4.class) @@ -50,6 +72,7 @@ public final class DefaultAudioSinkTest { private static final int CHANNEL_COUNT_MONO = 1; private static final int CHANNEL_COUNT_STEREO = 2; + private static final int DEFAULT_MAX_CHANNEL_COUNT = 8; private static final int BYTES_PER_FRAME_16_BIT = 2; private static final int SAMPLE_RATE_44_1 = 44100; private static final int TRIM_100_MS_FRAME_COUNT = 4410; @@ -63,6 +86,9 @@ public final class DefaultAudioSinkTest { private DefaultAudioSink defaultAudioSink; private ArrayAudioBufferSink arrayAudioBufferSink; + @Nullable private AudioDeviceInfo hdmiDevice; + @Nullable private AudioDeviceInfo bluetoothDevice; + @Before public void setUp() { // For capturing output. @@ -74,6 +100,12 @@ public final class DefaultAudioSinkTest { .build(); } + @After + public void tearDown() { + removeBluetoothDevice(); + removeHdmiDevice(); + } + @Test public void handlesSpecializedAudioProcessorArray() { defaultAudioSink = @@ -359,6 +391,331 @@ public final class DefaultAudioSinkTest { assertThat(defaultAudioSink.supportsFormat(aacLcFormat)).isFalse(); } + @Test + @Config(minSdk = 23) + public void audioSinkWithNonNullContext_audioCapabilitiesObtainedFromContext() { + // Set UI mode to TV. + getShadowUiModeManager().currentModeType = Configuration.UI_MODE_TYPE_TELEVISION; + addHdmiDevice(); + + DefaultAudioSink audioSink = + new DefaultAudioSink.Builder(ApplicationProvider.getApplicationContext()).build(); + + assertThat( + audioSink.supportsFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .setChannelCount(DEFAULT_MAX_CHANNEL_COUNT) + .build())) + .isTrue(); + } + + @Test + @SuppressWarnings("deprecation") // Testing deprecated builder methods. + public void audioSinkWithNullContext_audioCapabilitiesObtainedFromBuilder() { + DefaultAudioSink audioSink = + new DefaultAudioSink.Builder() + .setAudioCapabilities( + new AudioCapabilities( + new int[] {MimeTypes.getEncoding(MimeTypes.AUDIO_DTS_HD, /* codec= */ null)}, + DEFAULT_MAX_CHANNEL_COUNT)) + .build(); + + assertThat( + audioSink.supportsFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .setChannelCount(DEFAULT_MAX_CHANNEL_COUNT) + .build())) + .isTrue(); + } + + @Test + @SuppressWarnings("deprecation") // Testing deprecated builder methods. + public void audioSinkWithNullContext_audioCapabilitiesObtainedFromBuilder_defaultCapabilities() { + DefaultAudioSink audioSink = new DefaultAudioSink.Builder().build(); + + assertThat( + audioSink.supportsFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .setChannelCount(DEFAULT_MAX_CHANNEL_COUNT) + .build())) + .isFalse(); + } + + @Test + @Config(minSdk = 23) // AudioManager.TYPE_BLUETOOTH_A2DP is supported from API 23. + public void bluetoothDeviceAddedAndRemoved_audioCapabilitiesUpdated() { + // Set UI mode to TV. + getShadowUiModeManager().currentModeType = Configuration.UI_MODE_TYPE_TELEVISION; + // Initially setup the audio sink with HDMI device connected. + addHdmiDevice(); + DefaultAudioSink audioSink = + new DefaultAudioSink.Builder(ApplicationProvider.getApplicationContext()).build(); + assertThat( + audioSink.supportsFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .setChannelCount(DEFAULT_MAX_CHANNEL_COUNT) + .build())) + .isTrue(); + + // Add a bluetooth device. + addBluetoothDevice(); + // When the bluetooth device is connected, the audio sink should change to the default PCM + // capabilities, thus the surrounded format shouldn't be reported to support. + assertThat( + audioSink.supportsFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .setChannelCount(DEFAULT_MAX_CHANNEL_COUNT) + .build())) + .isFalse(); + + // Remove the bluetooth device. + removeBluetoothDevice(); + // When the bluetooth device is disconnected, the audio sink should change to the capabilities + // reported by the HDMI device, as that device is still connected. + assertThat( + audioSink.supportsFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .setChannelCount(DEFAULT_MAX_CHANNEL_COUNT) + .build())) + .isTrue(); + } + + @Test + @Config(minSdk = 21) // AudioManager.ACTION_HDMI_AUDIO_PLUG is supported from API 21. + public void hdmiDeviceAddedAndRemoved_audioCapabilitiesUpdated() { + // Set UI mode to TV. + getShadowUiModeManager().currentModeType = Configuration.UI_MODE_TYPE_TELEVISION; + // Initially setup the audio sink. + DefaultAudioSink audioSink = + new DefaultAudioSink.Builder(ApplicationProvider.getApplicationContext()).build(); + assertThat( + audioSink.supportsFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .setChannelCount(DEFAULT_MAX_CHANNEL_COUNT) + .build())) + .isFalse(); + + // Add an HDMI device. + addHdmiDevice(); + // When the HDMI device is connected, the audio sink should change to the capabilities reported + // by the HDMI device. + assertThat( + audioSink.supportsFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .setChannelCount(DEFAULT_MAX_CHANNEL_COUNT) + .build())) + .isTrue(); + + // Remove the HDMI device. + removeHdmiDevice(); + // When the HDMI device is disconnected, the audio sink should change to the default PCM + // capabilities. We are verifying the surround format reported by the HDMI device before is no + // longer supported. + assertThat( + audioSink.supportsFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .setChannelCount(DEFAULT_MAX_CHANNEL_COUNT) + .build())) + .isFalse(); + } + + // TODO: b/320191198 - Disable the test for API 33, as the + // ShadowAudioManager.getDirectProfilesForAttributes(AudioAttributes) hasn't really considered + // the AudioAttributes yet. + @Test + @Config(minSdk = 29, maxSdk = 32) + public void setAudioAttributes_audioCapabilitiesUpdated() { + Context context = ApplicationProvider.getApplicationContext(); + getShadowUiModeManager().setCurrentModeType(Configuration.UI_MODE_TYPE_TELEVISION); + ShadowAudioTrack.addDirectPlaybackSupport( + new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_E_AC3_JOC) + .setSampleRate(48_000) + .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO) + .build(), + new android.media.AudioAttributes.Builder() + .setUsage(android.media.AudioAttributes.USAGE_MEDIA) + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MUSIC) + .build()); + DefaultAudioSink audioSink = new DefaultAudioSink.Builder(context).build(); + Format expectedFormat = + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_E_AC3_JOC) + .setChannelCount(2) + .setSampleRate(48_000) + .build(); + AudioAttributes expectedAttributes = + new AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(); + AudioAttributes otherAttributes = + new AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build(); + + // Format supported with right attributes. + audioSink.setAudioAttributes(expectedAttributes); + assertThat(audioSink.supportsFormat(expectedFormat)).isTrue(); + // Format unsupported with other attributes. + audioSink.setAudioAttributes(otherAttributes); + assertThat(audioSink.supportsFormat(expectedFormat)).isFalse(); + } + + // Adding the permission to the test AndroidManifest.xml doesn't work to appease lint. + @SuppressWarnings({"StickyBroadcast", "MissingPermission"}) + @Test + @Config(minSdk = 23, maxSdk = 32) // AudioManager.TYPE_BLUETOOTH_A2DP is supported from API 23. + public void setPreferredDevice_audioCapabilitiesUpdated() { + // Initially setup the audio sink with Bluetooth and HDMI device connected. + AudioDeviceInfo hdmiDevice = + AudioDeviceInfoBuilder.newBuilder().setType(AudioDeviceInfo.TYPE_HDMI).build(); + AudioDeviceInfo bluetoothDevice = + AudioDeviceInfoBuilder.newBuilder().setType(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP).build(); + getShadowAudioManager().setOutputDevices(ImmutableList.of(hdmiDevice, bluetoothDevice)); + Intent intent = new Intent(AudioManager.ACTION_HDMI_AUDIO_PLUG); + intent.putExtra(AudioManager.EXTRA_AUDIO_PLUG_STATE, 1); + intent.putExtra( + AudioManager.EXTRA_ENCODINGS, + new int[] {MimeTypes.getEncoding(MimeTypes.AUDIO_DTS_HD, /* codec= */ null)}); + intent.putExtra(AudioManager.EXTRA_MAX_CHANNEL_COUNT, DEFAULT_MAX_CHANNEL_COUNT); + ApplicationProvider.getApplicationContext().sendStickyBroadcast(intent); + DefaultAudioSink audioSink = + new DefaultAudioSink.Builder(ApplicationProvider.getApplicationContext()).build(); + // Verify that surround sound is not supported assuming that Bluetooth is used. + Format surroundFormat = + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .setChannelCount(DEFAULT_MAX_CHANNEL_COUNT) + .build(); + assertThat(audioSink.supportsFormat(surroundFormat)).isFalse(); + + // Set the preferred device to HDMI and assert that the surround sound is now supported. + audioSink.setPreferredDevice(hdmiDevice); + + assertThat(audioSink.supportsFormat(surroundFormat)).isTrue(); + } + + // Adding the permission to the test AndroidManifest.xml doesn't work to appease lint. + @SuppressWarnings({"StickyBroadcast", "MissingPermission"}) + @Test + @Config(minSdk = 24, maxSdk = 32) // OnRoutingChangedListener is supported from API 24. + public void onRoutingChanged_onActiveAudioTrack_audioCapabilitiesUpdated() throws Exception { + // Initially setup the audio sink with Bluetooth and HDMI device connected. + AudioDeviceInfo hdmiDevice = + AudioDeviceInfoBuilder.newBuilder().setType(AudioDeviceInfo.TYPE_HDMI).build(); + AudioDeviceInfo bluetoothDevice = + AudioDeviceInfoBuilder.newBuilder().setType(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP).build(); + getShadowAudioManager().setOutputDevices(ImmutableList.of(hdmiDevice, bluetoothDevice)); + Intent intent = new Intent(AudioManager.ACTION_HDMI_AUDIO_PLUG); + intent.putExtra(AudioManager.EXTRA_AUDIO_PLUG_STATE, 1); + intent.putExtra( + AudioManager.EXTRA_ENCODINGS, + new int[] {MimeTypes.getEncoding(MimeTypes.AUDIO_DTS_HD, /* codec= */ null)}); + intent.putExtra(AudioManager.EXTRA_MAX_CHANNEL_COUNT, DEFAULT_MAX_CHANNEL_COUNT); + ApplicationProvider.getApplicationContext().sendStickyBroadcast(intent); + DefaultAudioSink audioSink = + new DefaultAudioSink.Builder(ApplicationProvider.getApplicationContext()).build(); + // Routing changes are only expected to work on active audio tracks. + Format format = + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_RAW) + .setPcmEncoding(C.ENCODING_PCM_16BIT) + .setChannelCount(2) + .setSampleRate(44100) + .build(); + audioSink.configure(format, /* specifiedBufferSize= */ 0, /* outputChannels= */ null); + ByteBuffer silenceBuffer = + ByteBuffer.allocateDirect(/* sample rate * bit depth * channels */ 44100 * 2 * 2) + .order(ByteOrder.nativeOrder()); + audioSink.handleBuffer( + silenceBuffer, /* presentationTimeUs= */ 0, /* encodedAccessUnitCount= */ 1); + // Verify that surround sound is not supported assuming that Bluetooth is used. + Format surroundFormat = + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .setChannelCount(DEFAULT_MAX_CHANNEL_COUNT) + .build(); + assertThat(audioSink.supportsFormat(surroundFormat)).isFalse(); + + // Changed the routing to HDMI and assert that the surround sound is now supported. + ShadowAudioTrack.setRoutedDevice(hdmiDevice); + ShadowLooper.idleMainLooper(); + + assertThat(audioSink.supportsFormat(surroundFormat)).isTrue(); + } + + @Test + @Config(minSdk = 23) // AudioManager.TYPE_BLUETOOTH_A2DP is supported from API 23. + public void afterRelease_bluetoothDeviceAdded_audioCapabilitiesShouldNotBeUpdated() { + // Set UI mode to TV. + getShadowUiModeManager().currentModeType = Configuration.UI_MODE_TYPE_TELEVISION; + // Initially setup the audio sink with HDMI device connected. + addHdmiDevice(); + DefaultAudioSink audioSink = + new DefaultAudioSink.Builder(ApplicationProvider.getApplicationContext()).build(); + assertThat( + audioSink.supportsFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .setChannelCount(DEFAULT_MAX_CHANNEL_COUNT) + .build())) + .isTrue(); + + audioSink.release(); + // Add a bluetooth device after release. + addBluetoothDevice(); + + // The audio sink should not change its capabilities. + assertThat( + audioSink.supportsFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .setChannelCount(DEFAULT_MAX_CHANNEL_COUNT) + .build())) + .isTrue(); + } + + @Test + @Config(minSdk = 21) // AudioManager.ACTION_HDMI_AUDIO_PLUG is supported from API 21. + public void afterRelease_hdmiDeviceAdded_audioCapabilitiesShouldNotBeUpdated() { + // Set UI mode to TV. + getShadowUiModeManager().currentModeType = Configuration.UI_MODE_TYPE_TELEVISION; + // Initially setup the audio sink. + DefaultAudioSink audioSink = + new DefaultAudioSink.Builder(ApplicationProvider.getApplicationContext()).build(); + assertThat( + audioSink.supportsFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .setChannelCount(DEFAULT_MAX_CHANNEL_COUNT) + .build())) + .isFalse(); + + audioSink.release(); + // Add an HDMI device after release. + addHdmiDevice(); + + // The audio sink should not change its capabilities. + assertThat( + audioSink.supportsFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .setChannelCount(DEFAULT_MAX_CHANNEL_COUNT) + .build())) + .isFalse(); + } + @Test public void configure_throwsConfigurationException_withInvalidInput() { Format format = new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).build(); @@ -414,6 +771,145 @@ public final class DefaultAudioSinkTest { defaultAudioSink.configure(format, /* specifiedBufferSize= */ 0, /* outputChannels= */ null); } + // Adding the permission to the test AndroidManifest.xml doesn't work to appease lint. + @SuppressWarnings({"StickyBroadcast", "MissingPermission"}) + private void addHdmiDevice() { + if (Util.SDK_INT >= 23) { + // AudioFormat.getChannelIndexMask() in the implementation of + // ShadowAudioTrack.addDirectPlaybackSupport requires API 23+. + // https://cs.android.com/android/platform/superproject/main/+/main:external/robolectric/shadows/framework/src/main/java/org/robolectric/shadows/ShadowAudioTrack.java?q=format.getChannelIndexMask() + ShadowAudioTrack.addAllowedNonPcmEncoding(AudioFormat.ENCODING_DTS_HD); + ShadowAudioTrack.addDirectPlaybackSupport( + new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_DTS_HD) + .setSampleRate(AudioCapabilities.DEFAULT_SAMPLE_RATE_HZ) + .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO) + .build(), + new android.media.AudioAttributes.Builder() + .setUsage(android.media.AudioAttributes.USAGE_MEDIA) + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_UNKNOWN) + .setFlags(0) + .build()); + // AudioDeviceInfoBuilder requires API 23+. + // https://cs.android.com/android/platform/superproject/main/+/main:external/robolectric/shadows/framework/src/main/java/org/robolectric/shadows/AudioDeviceInfoBuilder.java?q=VERSION_CODES.M + AudioDeviceInfoBuilder hdmiDeviceBuilder = + AudioDeviceInfoBuilder.newBuilder().setType(AudioDeviceInfo.TYPE_HDMI); + if (Util.SDK_INT >= 33) { + ImmutableList expectedProfiles = + ImmutableList.of( + AudioProfileBuilder.newBuilder() + .setFormat(AudioFormat.ENCODING_DTS_HD) + .setSamplingRates(new int[] {48_000}) + .setChannelMasks( + new int[] {Util.getAudioTrackChannelConfig(DEFAULT_MAX_CHANNEL_COUNT)}) + .setChannelIndexMasks(new int[] {}) + .setEncapsulationType(AudioProfile.AUDIO_ENCAPSULATION_TYPE_NONE) + .build(), + AudioProfileBuilder.newBuilder() + .setFormat(AudioFormat.ENCODING_PCM_16BIT) + .setSamplingRates(new int[] {48_000}) + .setChannelMasks(new int[] {AudioFormat.CHANNEL_OUT_STEREO}) + .setChannelIndexMasks(new int[] {}) + .setEncapsulationType(AudioProfile.AUDIO_ENCAPSULATION_TYPE_NONE) + .build()); + hdmiDeviceBuilder.setProfiles(expectedProfiles); + } + hdmiDevice = hdmiDeviceBuilder.build(); + getShadowAudioManager() + .addOutputDevice(checkNotNull(hdmiDevice), /* notifyAudioDeviceCallbacks= */ true); + getShadowAudioManager().addOutputDeviceWithDirectProfiles(checkNotNull(hdmiDevice)); + } + Intent intent = new Intent(AudioManager.ACTION_HDMI_AUDIO_PLUG); + intent.putExtra(AudioManager.EXTRA_AUDIO_PLUG_STATE, 1); + intent.putExtra( + AudioManager.EXTRA_ENCODINGS, + new int[] {MimeTypes.getEncoding(MimeTypes.AUDIO_DTS_HD, /* codec= */ null)}); + intent.putExtra(AudioManager.EXTRA_MAX_CHANNEL_COUNT, DEFAULT_MAX_CHANNEL_COUNT); + ApplicationProvider.getApplicationContext().sendStickyBroadcast(intent); + + shadowOf(Looper.getMainLooper()).idle(); + } + + // Adding the permission to the test AndroidManifest.xml doesn't work to appease lint. + @SuppressWarnings({"StickyBroadcast", "MissingPermission"}) + private void removeHdmiDevice() { + if (Util.SDK_INT >= 23 && hdmiDevice != null) { + ShadowAudioTrack.clearAllowedNonPcmEncodings(); + ShadowAudioTrack.clearDirectPlaybackSupportedFormats(); + getShadowAudioManager().removeOutputDeviceWithDirectProfiles(hdmiDevice); + getShadowAudioManager() + .removeOutputDevice(checkNotNull(hdmiDevice), /* notifyAudioDeviceCallbacks= */ true); + + getShadowAudioManager().removeOutputDeviceWithDirectProfiles(hdmiDevice); + hdmiDevice = null; + } + Intent intent = new Intent(AudioManager.ACTION_HDMI_AUDIO_PLUG); + intent.putExtra(AudioManager.EXTRA_AUDIO_PLUG_STATE, 0); + ApplicationProvider.getApplicationContext().sendStickyBroadcast(intent); + + shadowOf(Looper.getMainLooper()).idle(); + } + + private void addBluetoothDevice() { + if (Util.SDK_INT >= 23) { + // For API 33+, AudioManager.getDirectProfilesForAttributes returns the AudioProfile for the + // routed device. To simulate the Bluetooth is connected and routed, we need to remove the + // profile of the HDMI device, which means that the HDMI device is no longer routed, but + // still be connected. + removeHdmiDevice(); + AudioDeviceInfoBuilder bluetoothDeviceBuilder = + AudioDeviceInfoBuilder.newBuilder().setType(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP); + if (Util.SDK_INT >= 33) { + bluetoothDeviceBuilder.setProfiles(ImmutableList.of(createPcmProfile())); + } + bluetoothDevice = bluetoothDeviceBuilder.build(); + getShadowAudioManager() + .addOutputDevice(checkNotNull(bluetoothDevice), /* notifyAudioDeviceCallbacks= */ true); + getShadowAudioManager().addOutputDeviceWithDirectProfiles(checkNotNull(bluetoothDevice)); + } + + shadowOf(Looper.getMainLooper()).idle(); + } + + private void removeBluetoothDevice() { + if (Util.SDK_INT >= 23 && bluetoothDevice != null) { + // Add back the HDMI device back as the routed device to simulate that the bluetooth device + // has gone and is no longer routed. + addHdmiDevice(); + getShadowAudioManager().removeOutputDeviceWithDirectProfiles(checkNotNull(bluetoothDevice)); + getShadowAudioManager() + .removeOutputDevice( + checkNotNull(bluetoothDevice), /* notifyAudioDeviceCallbacks= */ true); + bluetoothDevice = null; + } + + shadowOf(Looper.getMainLooper()).idle(); + } + + private static ShadowUIModeManager getShadowUiModeManager() { + return shadowOf( + (UiModeManager) + ApplicationProvider.getApplicationContext().getSystemService(Context.UI_MODE_SERVICE)); + } + + private static ShadowAudioManager getShadowAudioManager() { + return shadowOf( + (AudioManager) + ApplicationProvider.getApplicationContext().getSystemService(Context.AUDIO_SERVICE)); + } + + @SuppressWarnings("UseSdkSuppress") // https://issuetracker.google.com/382253664 + @RequiresApi(33) + private static AudioProfile createPcmProfile() { + return AudioProfileBuilder.newBuilder() + .setFormat(AudioFormat.ENCODING_PCM_16BIT) + .setSamplingRates(new int[] {48_000}) + .setChannelMasks(new int[] {AudioFormat.CHANNEL_OUT_STEREO}) + .setChannelIndexMasks(new int[] {}) + .setEncapsulationType(AudioProfile.AUDIO_ENCAPSULATION_TYPE_NONE) + .build(); + } + /** Creates a one second silence buffer for 44.1 kHz stereo 16-bit audio. */ private static ByteBuffer create1Sec44100HzSilenceBuffer() { return ByteBuffer.allocateDirect(