diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index aa71fc257c..ee3e6d1c72 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -67,6 +67,11 @@
* IMA extension:
* Session:
* UI:
+ * Add a `Player.Listener` implementation for Wear OS devices that handles
+ playback suppression due to
+ `Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT` by
+ launching a system dialog to allow a user to connect a suitable audio
+ output (e.g. bluetooth headphones).
* Downloads:
* OkHttp Extension:
* Cronet Extension:
diff --git a/libraries/ui/src/main/java/androidx/media3/ui/WearUnsuitableOutputPlaybackSuppressionResolverListener.java b/libraries/ui/src/main/java/androidx/media3/ui/WearUnsuitableOutputPlaybackSuppressionResolverListener.java
new file mode 100644
index 0000000000..4081f3f58b
--- /dev/null
+++ b/libraries/ui/src/main/java/androidx/media3/ui/WearUnsuitableOutputPlaybackSuppressionResolverListener.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright (C) 2023 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.ui;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.provider.Settings;
+import androidx.annotation.Nullable;
+import androidx.media3.common.Player;
+import androidx.media3.common.Player.Events;
+import androidx.media3.common.util.UnstableApi;
+import java.util.List;
+
+/**
+ * A {@link Player.Listener} that launches a system dialog in response to {@link
+ * Player#PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT} to allow the user to connect a
+ * suitable audio output.
+ *
+ *
This listener only reacts to {@link
+ * Player#PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT} on Wear OS devices, while being no-op
+ * for non-Wear OS devices.
+ *
+ *
The system dialog will be the Media
+ * Output Switcher if it is available on the device, or otherwise the Bluetooth settings screen.
+ *
+ *
This implementation also pauses playback when launching the system dialog. The underlying
+ * {@link Player} implementation (e.g. ExoPlayer) is expected to resume playback automatically when
+ * a suitable audio device is connected by the user.
+ */
+@UnstableApi
+public final class WearUnsuitableOutputPlaybackSuppressionResolverListener
+ implements Player.Listener {
+
+ /** Output switcher intent action for the Wear OS. */
+ private static final String OUTPUT_SWITCHER_INTENT_ACTION_NAME =
+ "com.android.settings.panel.action.MEDIA_OUTPUT";
+
+ /** A package name key for output switcher intent in the Wear OS. */
+ private static final String EXTRA_OUTPUT_SWITCHER_PACKAGE_NAME =
+ "com.android.settings.panel.extra.PACKAGE_NAME";
+
+ /**
+ * Extra in the Bluetooth Activity intent to control whether the fragment should close when a
+ * device connects.
+ */
+ private static final String EXTRA_BLUETOOTH_SETTINGS_CLOSE_ON_CONNECT = "EXTRA_CLOSE_ON_CONNECT";
+
+ /**
+ * Extra in the Bluetooth Activity intent to indicate that the user only wants to connect or
+ * disconnect, not forget paired devices or do any other device management.
+ */
+ private static final String EXTRA_BLUETOOTH_SETTINGS_CONNECTION_ONLY = "EXTRA_CONNECTION_ONLY";
+
+ /**
+ * Extra in the Bluetooth Activity intent to specify the type of filtering that needs to be be
+ * applied to the device list.
+ */
+ private static final String EXTRA_BLUETOOTH_SETTINGS_FILTER_TYPE =
+ "android.bluetooth.devicepicker.extra.FILTER_TYPE";
+
+ /**
+ * The value for the {@link #EXTRA_BLUETOOTH_SETTINGS_FILTER_TYPE} in the Bluetooth intent to show
+ * BT devices that support AUDIO profiles
+ */
+ private static final int FILTER_TYPE_AUDIO = 1;
+
+ private Context applicationContext;
+
+ /**
+ * Creates a new {@link WearUnsuitableOutputPlaybackSuppressionResolverListener} instance.
+ *
+ * @param context Any context.
+ */
+ public WearUnsuitableOutputPlaybackSuppressionResolverListener(Context context) {
+ applicationContext = context.getApplicationContext();
+ }
+
+ @Override
+ public void onEvents(Player player, Events events) {
+ if (!isRunningOnWear(applicationContext)) {
+ return;
+ }
+ if (events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)
+ && player.getPlayWhenReady()
+ && player.getPlaybackSuppressionReason()
+ == Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT) {
+ player.pause();
+ launchSystemMediaOutputSwitcherUi(applicationContext);
+ }
+ }
+
+ /**
+ * Launches the system media output switcher app if it is available on the device, or otherwise
+ * the Bluetooth settings screen.
+ */
+ private static void launchSystemMediaOutputSwitcherUi(Context context) {
+ Intent outputSwitcherLaunchIntent =
+ new Intent(OUTPUT_SWITCHER_INTENT_ACTION_NAME)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ .putExtra(EXTRA_OUTPUT_SWITCHER_PACKAGE_NAME, context.getPackageName());
+ ComponentName outputSwitcherSystemComponentName =
+ getSystemOrSystemUpdatedAppComponent(context, outputSwitcherLaunchIntent);
+ if (outputSwitcherSystemComponentName != null) {
+ outputSwitcherLaunchIntent.setComponent(outputSwitcherSystemComponentName);
+ context.startActivity(outputSwitcherLaunchIntent);
+ } else {
+ Intent bluetoothSettingsLaunchIntent =
+ new Intent(Settings.ACTION_BLUETOOTH_SETTINGS)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ .putExtra(EXTRA_BLUETOOTH_SETTINGS_CLOSE_ON_CONNECT, true)
+ .putExtra(EXTRA_BLUETOOTH_SETTINGS_CONNECTION_ONLY, true)
+ .putExtra(EXTRA_BLUETOOTH_SETTINGS_FILTER_TYPE, FILTER_TYPE_AUDIO);
+ ComponentName bluetoothSettingsSystemComponentName =
+ getSystemOrSystemUpdatedAppComponent(context, bluetoothSettingsLaunchIntent);
+ if (bluetoothSettingsSystemComponentName != null) {
+ bluetoothSettingsLaunchIntent.setComponent(bluetoothSettingsSystemComponentName);
+ context.startActivity(bluetoothSettingsLaunchIntent);
+ }
+ }
+ }
+
+ /**
+ * Returns {@link ComponentName} of system or updated system app's activity resolved from the
+ * {@link Intent} passed to it.
+ */
+ private static @Nullable ComponentName getSystemOrSystemUpdatedAppComponent(
+ Context context, Intent intent) {
+ PackageManager packageManager = context.getPackageManager();
+ List resolveInfos = packageManager.queryIntentActivities(intent, /* flags= */ 0);
+ for (ResolveInfo resolveInfo : resolveInfos) {
+ ActivityInfo activityInfo = resolveInfo.activityInfo;
+ if (activityInfo == null || activityInfo.applicationInfo == null) {
+ continue;
+ }
+ ApplicationInfo appInfo = activityInfo.applicationInfo;
+ int systemAndUpdatedSystemAppFlags =
+ ApplicationInfo.FLAG_SYSTEM | ApplicationInfo.FLAG_UPDATED_SYSTEM_APP;
+ if ((systemAndUpdatedSystemAppFlags & appInfo.flags) != 0) {
+ return new ComponentName(activityInfo.packageName, activityInfo.name);
+ }
+ }
+ return null;
+ }
+
+ private static boolean isRunningOnWear(Context context) {
+ PackageManager packageManager = context.getPackageManager();
+ return packageManager.hasSystemFeature(PackageManager.FEATURE_WATCH);
+ }
+}
diff --git a/libraries/ui/src/test/java/androidx/media3/ui/WearUnsuitableOutputPlaybackSuppressionResolverListenerTest.java b/libraries/ui/src/test/java/androidx/media3/ui/WearUnsuitableOutputPlaybackSuppressionResolverListenerTest.java
new file mode 100644
index 0000000000..b4f80e3ffc
--- /dev/null
+++ b/libraries/ui/src/test/java/androidx/media3/ui/WearUnsuitableOutputPlaybackSuppressionResolverListenerTest.java
@@ -0,0 +1,468 @@
+/*
+ * Copyright (C) 2023 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.ui;
+
+import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilPlaybackState;
+import static androidx.test.ext.truth.content.IntentSubject.assertThat;
+import static com.google.common.truth.Truth.assertThat;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.media.AudioDeviceInfo;
+import android.media.AudioManager;
+import android.os.Looper;
+import android.provider.Settings;
+import androidx.media3.common.FlagSet;
+import androidx.media3.common.MediaItem;
+import androidx.media3.common.Player;
+import androidx.media3.common.Player.Listener;
+import androidx.media3.test.utils.TestExoPlayerBuilder;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.shadows.AudioDeviceInfoBuilder;
+import org.robolectric.shadows.ShadowApplication;
+import org.robolectric.shadows.ShadowAudioManager;
+import org.robolectric.shadows.ShadowPackageManager;
+
+/** Tests for the {@link WearUnsuitableOutputPlaybackSuppressionResolverListener}. */
+@RunWith(AndroidJUnit4.class)
+public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest {
+
+ private static final String OUTPUT_SWITCHER_INTENT_ACTION_NAME =
+ "com.android.settings.panel.action.MEDIA_OUTPUT";
+ private static final String FAKE_SYSTEM_OUTPUT_SWITCHER_PACKAGE_NAME = "com.fake.outputswitcher";
+ private static final String FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME =
+ "com.fake.outputswitcher.OutputSwitcherActivity";
+ private static final String FAKE_SYSTEM_BT_SETTINGS_PACKAGE_NAME = "com.fake.btsettings";
+ private static final String FAKE_SYSTEM_BT_SETTINGS_CLASS_NAME =
+ "com.fake.btsettings.BluetoothSettingsActivity";
+
+ private ShadowPackageManager shadowPackageManager;
+ private ShadowApplication shadowApplication;
+ private Player testPlayer;
+
+ @Before
+ public void setUp() {
+ testPlayer =
+ new TestExoPlayerBuilder(ApplicationProvider.getApplicationContext())
+ .setSuppressPlaybackOnUnsuitableOutput(true)
+ .build();
+ testPlayer.addListener(
+ new WearUnsuitableOutputPlaybackSuppressionResolverListener(
+ ApplicationProvider.getApplicationContext()));
+ shadowApplication = shadowOf((Application) ApplicationProvider.getApplicationContext());
+ shadowPackageManager =
+ shadowOf(ApplicationProvider.getApplicationContext().getPackageManager());
+ }
+
+ @After
+ public void afterTest() {
+ testPlayer.release();
+ }
+
+ /**
+ * Test for the launch of system Output Switcher app when playback is suppressed due to unsuitable
+ * output and the system Output Switcher is present on the device.
+ */
+ @Test
+ public void
+ playEventWithPlaybackSuppressionWhenSystemOutputSwitcherPresent_shouldLaunchOutputSwitcher()
+ throws TimeoutException {
+ shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ true);
+ registerFakeActivity(
+ OUTPUT_SWITCHER_INTENT_ACTION_NAME,
+ FAKE_SYSTEM_OUTPUT_SWITCHER_PACKAGE_NAME,
+ FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME,
+ ApplicationInfo.FLAG_SYSTEM);
+ setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
+ testPlayer.setMediaItem(
+ MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
+ testPlayer.prepare();
+
+ testPlayer.play();
+ runUntilPlaybackState(testPlayer, Player.STATE_READY);
+
+ Intent intentTriggered = shadowApplication.getNextStartedActivity();
+ assertThat(intentTriggered).isNotNull();
+ assertThat(intentTriggered).hasAction(OUTPUT_SWITCHER_INTENT_ACTION_NAME);
+ assertThat(intentTriggered)
+ .hasComponent(
+ FAKE_SYSTEM_OUTPUT_SWITCHER_PACKAGE_NAME, FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME);
+ }
+
+ /**
+ * Test for the launch of system updated Output Switcher app when playback is suppressed due to
+ * unsuitable output and the system updated Output Switcher is present on the device.
+ */
+ @Test
+ public void playEventWithPlaybackSuppressionWhenUpdatedSystemOSwPresent_shouldLaunchOSw()
+ throws TimeoutException {
+ shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ true);
+ registerFakeActivity(
+ OUTPUT_SWITCHER_INTENT_ACTION_NAME,
+ FAKE_SYSTEM_OUTPUT_SWITCHER_PACKAGE_NAME,
+ FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME,
+ ApplicationInfo.FLAG_UPDATED_SYSTEM_APP);
+ setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
+ testPlayer.setMediaItem(
+ MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
+ testPlayer.prepare();
+
+ testPlayer.play();
+ runUntilPlaybackState(testPlayer, Player.STATE_READY);
+
+ Intent intentTriggered = shadowApplication.getNextStartedActivity();
+ assertThat(intentTriggered).isNotNull();
+ assertThat(intentTriggered).hasAction(OUTPUT_SWITCHER_INTENT_ACTION_NAME);
+ assertThat(intentTriggered)
+ .hasComponent(
+ FAKE_SYSTEM_OUTPUT_SWITCHER_PACKAGE_NAME, FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME);
+ }
+
+ /**
+ * Test for the launch of system Output Switcher app when playback is suppressed due to unsuitable
+ * output and both the system as well as user installed Output Switcher are present on the device.
+ */
+ @Test
+ public void
+ playbackSuppressionWhenBothSystemAndUserInstalledOutputSwitcherPresent_shouldLaunchSystemOSw()
+ throws TimeoutException {
+ shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ true);
+ registerFakeActivity(
+ OUTPUT_SWITCHER_INTENT_ACTION_NAME,
+ FAKE_SYSTEM_OUTPUT_SWITCHER_PACKAGE_NAME,
+ FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME,
+ ApplicationInfo.FLAG_SYSTEM);
+ registerFakeActivity(
+ OUTPUT_SWITCHER_INTENT_ACTION_NAME,
+ "com.fake.userinstalled.outputswitcher",
+ "com.fake.userinstalled.outputswitcher.OutputSwitcherActivity",
+ /* applicationFlags= */ 0);
+ setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
+ testPlayer.setMediaItem(
+ MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
+ testPlayer.prepare();
+
+ testPlayer.play();
+ runUntilPlaybackState(testPlayer, Player.STATE_READY);
+
+ Intent intentTriggered = shadowApplication.getNextStartedActivity();
+ assertThat(intentTriggered).isNotNull();
+ assertThat(intentTriggered).hasAction(OUTPUT_SWITCHER_INTENT_ACTION_NAME);
+ assertThat(intentTriggered)
+ .hasComponent(
+ FAKE_SYSTEM_OUTPUT_SWITCHER_PACKAGE_NAME, FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME);
+ }
+
+ /**
+ * Test for no launch of system Output Switcher app when running on non-Wear OS device with
+ * playback suppression conditions and the system Output Switcher present on the device.
+ */
+ @Test
+ public void
+ playEventWithPlaybackSuppressionConditionsOnNonWearOSDevice_shouldNotLaunchOutputSwitcher()
+ throws TimeoutException {
+ shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ true);
+ registerFakeActivity(
+ OUTPUT_SWITCHER_INTENT_ACTION_NAME,
+ FAKE_SYSTEM_OUTPUT_SWITCHER_PACKAGE_NAME,
+ FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME,
+ ApplicationInfo.FLAG_SYSTEM);
+ setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
+ testPlayer.setMediaItem(
+ MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
+ testPlayer.prepare();
+ testPlayer.play();
+ shadowApplication.clearNextStartedActivities();
+ // Clear the system feature for "watch" to test on the non-Wear OS devices.
+ shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ false);
+ Player.Listener testPlayerListener =
+ new WearUnsuitableOutputPlaybackSuppressionResolverListener(
+ ApplicationProvider.getApplicationContext());
+ testPlayer.addListener(testPlayerListener);
+ runUntilPlaybackState(testPlayer, Player.STATE_READY);
+
+ testPlayerListener.onEvents(
+ testPlayer,
+ new Player.Events(new FlagSet.Builder().add(Player.EVENT_PLAY_WHEN_READY_CHANGED).build()));
+ shadowOf(Looper.getMainLooper()).idle();
+
+ Intent activityIntentTriggered = shadowApplication.getNextStartedActivity();
+ assertThat(activityIntentTriggered).isNull();
+ List broadcastIntents = shadowApplication.getBroadcastIntents();
+ assertThat(broadcastIntents).isEmpty();
+ }
+
+ /**
+ * Test for the launch of Bluetooth Settings app when playback is suppressed due to unsuitable
+ * output with the system Bluetooth Settings app present while the system Output Switcher app is
+ * not present on the device.
+ */
+ @Test
+ public void
+ playEventWithPlaybackSuppressionWhenOnlySystemBTSettingsPresent_shouldLaunchBTSettings()
+ throws TimeoutException {
+ shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ true);
+ registerFakeActivity(
+ Settings.ACTION_BLUETOOTH_SETTINGS,
+ FAKE_SYSTEM_BT_SETTINGS_PACKAGE_NAME,
+ FAKE_SYSTEM_BT_SETTINGS_CLASS_NAME,
+ ApplicationInfo.FLAG_SYSTEM);
+ setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
+ testPlayer.setMediaItem(
+ MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
+ testPlayer.prepare();
+
+ testPlayer.play();
+ runUntilPlaybackState(testPlayer, Player.STATE_READY);
+
+ Intent intentTriggered = shadowApplication.getNextStartedActivity();
+ assertThat(intentTriggered).isNotNull();
+ assertThat(intentTriggered).hasAction(Settings.ACTION_BLUETOOTH_SETTINGS);
+ assertThat(intentTriggered)
+ .hasComponent(FAKE_SYSTEM_BT_SETTINGS_PACKAGE_NAME, FAKE_SYSTEM_BT_SETTINGS_CLASS_NAME);
+ }
+
+ /**
+ * Test for the launch of Bluetooth Settings app when playback is suppressed due to unsuitable
+ * output with the updated system Bluetooth Settings app present while the Output Switcher app is
+ * not present on the device.
+ */
+ @Test
+ public void playbackSuppressionWhenOnlyUpdatedSystemBTSettingsPresent_shouldLaunchBTSettings()
+ throws TimeoutException {
+ shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ true);
+ registerFakeActivity(
+ Settings.ACTION_BLUETOOTH_SETTINGS,
+ FAKE_SYSTEM_BT_SETTINGS_PACKAGE_NAME,
+ FAKE_SYSTEM_BT_SETTINGS_CLASS_NAME,
+ ApplicationInfo.FLAG_SYSTEM);
+ setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
+ testPlayer.setMediaItem(
+ MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
+ testPlayer.prepare();
+
+ testPlayer.play();
+ runUntilPlaybackState(testPlayer, Player.STATE_READY);
+
+ Intent intentTriggered = shadowApplication.getNextStartedActivity();
+ assertThat(intentTriggered).isNotNull();
+ assertThat(intentTriggered).hasAction(Settings.ACTION_BLUETOOTH_SETTINGS);
+ assertThat(intentTriggered)
+ .hasComponent(FAKE_SYSTEM_BT_SETTINGS_PACKAGE_NAME, FAKE_SYSTEM_BT_SETTINGS_CLASS_NAME);
+ }
+
+ /**
+ * Test for the launch of Output Switcher app when playback is suppressed due to unsuitable output
+ * and both Output Switcher as well as the Bluetooth settings are present on the device.
+ */
+ @Test
+ public void playEventWithPlaybackSuppressionWhenOSwAndBTSettingsBothPresent_shouldLaunchOSw()
+ throws TimeoutException {
+ shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ true);
+ registerFakeActivity(
+ OUTPUT_SWITCHER_INTENT_ACTION_NAME,
+ FAKE_SYSTEM_OUTPUT_SWITCHER_PACKAGE_NAME,
+ FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME,
+ ApplicationInfo.FLAG_SYSTEM);
+ registerFakeActivity(
+ Settings.ACTION_BLUETOOTH_SETTINGS,
+ FAKE_SYSTEM_BT_SETTINGS_PACKAGE_NAME,
+ FAKE_SYSTEM_BT_SETTINGS_CLASS_NAME,
+ ApplicationInfo.FLAG_SYSTEM);
+ setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
+ testPlayer.setMediaItem(
+ MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
+ testPlayer.prepare();
+
+ testPlayer.play();
+ runUntilPlaybackState(testPlayer, Player.STATE_READY);
+
+ Intent intentTriggered = shadowApplication.getNextStartedActivity();
+ assertThat(intentTriggered).isNotNull();
+ assertThat(intentTriggered).hasAction(OUTPUT_SWITCHER_INTENT_ACTION_NAME);
+ assertThat(intentTriggered)
+ .hasComponent(
+ FAKE_SYSTEM_OUTPUT_SWITCHER_PACKAGE_NAME, FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME);
+ }
+
+ /**
+ * Test for no launch of the non-system and non-system updated Output Switcher app when playback
+ * is suppressed due to unsuitable output.
+ */
+ @Test
+ public void
+ playbackSuppressionWhenOnlyUserInstalledOSwAndBTSettingsPresent_shouldNotLaunchAnyApp()
+ throws TimeoutException {
+ shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ true);
+ registerFakeActivity(
+ OUTPUT_SWITCHER_INTENT_ACTION_NAME,
+ "com.fake.userinstalled.outputswitcher",
+ "com.fake.userinstalled.outputswitcher.OutputSwitcherActivity",
+ /* applicationFlags= */ 0);
+ registerFakeActivity(
+ Settings.ACTION_BLUETOOTH_SETTINGS,
+ "com.fake.userinstalled.btsettings",
+ "com.fake.userinstalled.btsettings.BluetoothSettingsActivity",
+ /* applicationFlags= */ 0);
+ setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
+ testPlayer.setMediaItem(
+ MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
+ testPlayer.prepare();
+
+ testPlayer.play();
+ runUntilPlaybackState(testPlayer, Player.STATE_READY);
+
+ Intent intentTriggered = shadowApplication.getNextStartedActivity();
+ assertThat(intentTriggered).isNull();
+ }
+
+ /**
+ * Test for no launch of any system media output switching dialog app when playback is not
+ * suppressed due to unsuitable output.
+ */
+ @Test
+ public void playEventWithoutPlaybackSuppression_shouldNotLaunchEitherOSwOrBTSettings()
+ throws TimeoutException {
+ shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ true);
+ registerFakeActivity(
+ OUTPUT_SWITCHER_INTENT_ACTION_NAME,
+ FAKE_SYSTEM_OUTPUT_SWITCHER_PACKAGE_NAME,
+ FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME,
+ ApplicationInfo.FLAG_SYSTEM);
+ registerFakeActivity(
+ Settings.ACTION_BLUETOOTH_SETTINGS,
+ FAKE_SYSTEM_BT_SETTINGS_PACKAGE_NAME,
+ FAKE_SYSTEM_BT_SETTINGS_CLASS_NAME,
+ ApplicationInfo.FLAG_SYSTEM);
+ setupConnectedAudioOutput(
+ AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP);
+ testPlayer.setMediaItem(
+ MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
+ testPlayer.prepare();
+
+ testPlayer.play();
+ runUntilPlaybackState(testPlayer, Player.STATE_READY);
+
+ assertThat(shadowApplication.getNextStartedActivity()).isNull();
+ }
+
+ /** Test for pause on the Player when the playback is suppressed due to unsuitable output. */
+ @Test
+ public void playEventWithSuppressedPlaybackCondition_shouldCallPauseOnPlayer()
+ throws TimeoutException {
+ shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ true);
+ registerFakeActivity(
+ OUTPUT_SWITCHER_INTENT_ACTION_NAME,
+ FAKE_SYSTEM_OUTPUT_SWITCHER_PACKAGE_NAME,
+ FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME,
+ ApplicationInfo.FLAG_SYSTEM);
+ setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER);
+ testPlayer.setMediaItem(
+ MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
+ testPlayer.prepare();
+ AtomicBoolean isPlaybackPaused = new AtomicBoolean(false);
+ testPlayer.addListener(
+ new Listener() {
+ @Override
+ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) {
+ if (!playWhenReady) {
+ isPlaybackPaused.set(true);
+ }
+ }
+ });
+
+ testPlayer.play();
+ runUntilPlaybackState(testPlayer, Player.STATE_READY);
+
+ assertThat(isPlaybackPaused.get()).isTrue();
+ }
+
+ /**
+ * Test for no pause on the Player when the playback is not suppressed due to unsuitable output.
+ */
+ @Test
+ public void playEventWithoutSuppressedPlaybackCondition_shouldNotCallPauseOnPlayer()
+ throws TimeoutException {
+ shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ true);
+ registerFakeActivity(
+ OUTPUT_SWITCHER_INTENT_ACTION_NAME,
+ FAKE_SYSTEM_OUTPUT_SWITCHER_PACKAGE_NAME,
+ FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME,
+ ApplicationInfo.FLAG_SYSTEM);
+ setupConnectedAudioOutput(
+ AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP);
+ testPlayer.setMediaItem(
+ MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4"));
+ testPlayer.prepare();
+ AtomicBoolean isPlaybackPaused = new AtomicBoolean(false);
+ testPlayer.addListener(
+ new Listener() {
+ @Override
+ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) {
+ if (!playWhenReady) {
+ isPlaybackPaused.set(true);
+ }
+ }
+ });
+
+ testPlayer.play();
+ runUntilPlaybackState(testPlayer, Player.STATE_READY);
+
+ assertThat(isPlaybackPaused.get()).isFalse();
+ }
+
+ private void registerFakeActivity(
+ String fakeActionName, String fakePackageName, String fakeClassName, int applicationFlags) {
+ ComponentName fakeComponentName = new ComponentName(fakePackageName, fakeClassName);
+
+ ApplicationInfo systemAppInfo = new ApplicationInfo();
+ systemAppInfo.flags |= applicationFlags;
+
+ ActivityInfo fakeActivityInfo = new ActivityInfo();
+ fakeActivityInfo.applicationInfo = systemAppInfo;
+ fakeActivityInfo.name = fakeComponentName.getClassName();
+ fakeActivityInfo.packageName = fakeComponentName.getPackageName();
+
+ shadowPackageManager.addOrUpdateActivity(fakeActivityInfo);
+ shadowPackageManager.addIntentFilterForActivity(
+ fakeComponentName, new IntentFilter(fakeActionName));
+ }
+
+ private void setupConnectedAudioOutput(int... deviceTypes) {
+ ShadowAudioManager shadowAudioManager =
+ shadowOf(ApplicationProvider.getApplicationContext().getSystemService(AudioManager.class));
+ ImmutableList.Builder deviceListBuilder = ImmutableList.builder();
+ for (int deviceType : deviceTypes) {
+ deviceListBuilder.add(AudioDeviceInfoBuilder.newBuilder().setType(deviceType).build());
+ }
+ shadowAudioManager.setOutputDevices(deviceListBuilder.build());
+ }
+}