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()); + } +}