diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 72ad4efbc7..0712da9f0b 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -79,7 +79,9 @@ 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). + output (e.g. bluetooth headphones). The listener will auto-resume + playback if a suitable device is connected within a configurable timeout + (default is 5 minutes). * 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 index 382fd1c417..b96476da18 100644 --- a/libraries/ui/src/main/java/androidx/media3/ui/WearUnsuitableOutputPlaybackSuppressionResolverListener.java +++ b/libraries/ui/src/main/java/androidx/media3/ui/WearUnsuitableOutputPlaybackSuppressionResolverListener.java @@ -15,6 +15,9 @@ */ package androidx.media3.ui; +import static androidx.media3.common.util.Assertions.checkArgument; +import static java.util.concurrent.TimeUnit.MINUTES; + import android.content.ComponentName; import android.content.Context; import android.content.Intent; @@ -23,9 +26,14 @@ import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.provider.Settings; +import androidx.annotation.IntRange; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.media3.common.C; import androidx.media3.common.Player; import androidx.media3.common.Player.Events; +import androidx.media3.common.util.Clock; +import androidx.media3.common.util.SystemClock; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import java.util.List; @@ -33,7 +41,9 @@ 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. + * suitable audio output. Also, it auto-resumes the playback when the playback suppression reason is + * changed from {@link Player#PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT} to {@link + * Player#PLAYBACK_SUPPRESSION_REASON_NONE}. * *

This listener only reacts to {@link * Player#PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT} on Wear OS devices, while being no-op @@ -84,15 +94,56 @@ public final class WearUnsuitableOutputPlaybackSuppressionResolverListener */ private static final int FILTER_TYPE_AUDIO = 1; - private Context applicationContext; + /** + * The default timeout for auto-resume of suppressed playback when the playback suppression reason + * as {@link Player#PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT} is removed, in + * milliseconds. + */ + public static final long DEFAULT_PLAYBACK_SUPPRESSION_AUTO_RESUME_TIMEOUT_MS = + MINUTES.toMillis(5); + + private final Context applicationContext; + private final long autoResumeTimeoutAfterUnsuitableOutputSuppressionMs; + private final Clock clock; + + private long unsuitableOutputPlaybackSuppressionStartRealtimeMs; /** - * Creates a new {@link WearUnsuitableOutputPlaybackSuppressionResolverListener} instance. + * Creates a new instance. + * + *

See {@link #WearUnsuitableOutputPlaybackSuppressionResolverListener(Context, long)} for more + * details. The auto-resume timeout defaults to {@link + * #DEFAULT_PLAYBACK_SUPPRESSION_AUTO_RESUME_TIMEOUT_MS}. * * @param context Any context. */ public WearUnsuitableOutputPlaybackSuppressionResolverListener(Context context) { + this(context, DEFAULT_PLAYBACK_SUPPRESSION_AUTO_RESUME_TIMEOUT_MS); + } + + /** + * Creates a new instance. + * + * @param context Any context. + * @param autoResumeTimeoutMs Duration in milliseconds after the playback suppression during which + * playback will be resumed automatically if the playback suppression reason is changed from + * {@link Player#PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT} to {@link + * Player#PLAYBACK_SUPPRESSION_REASON_NONE}. Calling with {@code autoResumeTimeoutMs = 0} will + * cause playback to never resume automatically. + */ + public WearUnsuitableOutputPlaybackSuppressionResolverListener( + Context context, @IntRange(from = 0) long autoResumeTimeoutMs) { + this(context, autoResumeTimeoutMs, SystemClock.DEFAULT); + } + + @VisibleForTesting + /* package */ WearUnsuitableOutputPlaybackSuppressionResolverListener( + Context context, @IntRange(from = 0) long autoResumeTimeoutMs, Clock clock) { + checkArgument(autoResumeTimeoutMs >= 0); applicationContext = context.getApplicationContext(); + autoResumeTimeoutAfterUnsuitableOutputSuppressionMs = autoResumeTimeoutMs; + this.clock = clock; + unsuitableOutputPlaybackSuppressionStartRealtimeMs = C.TIME_UNSET; } @Override @@ -100,12 +151,23 @@ public final class WearUnsuitableOutputPlaybackSuppressionResolverListener if (!Util.isWear(applicationContext)) { return; } - if (events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED) + if ((events.contains(Player.EVENT_PLAYBACK_SUPPRESSION_REASON_CHANGED) + || events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) && player.getPlayWhenReady() && player.getPlaybackSuppressionReason() == Player.PLAYBACK_SUPPRESSION_REASON_UNSUITABLE_AUDIO_OUTPUT) { player.pause(); - launchSystemMediaOutputSwitcherUi(applicationContext); + if (events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) { + launchSystemMediaOutputSwitcherUi(applicationContext); + unsuitableOutputPlaybackSuppressionStartRealtimeMs = clock.elapsedRealtime(); + } + } else if (events.contains(Player.EVENT_PLAYBACK_SUPPRESSION_REASON_CHANGED) + && player.getPlaybackSuppressionReason() == Player.PLAYBACK_SUPPRESSION_REASON_NONE + && unsuitableOutputPlaybackSuppressionStartRealtimeMs != C.TIME_UNSET + && (clock.elapsedRealtime() - unsuitableOutputPlaybackSuppressionStartRealtimeMs + < autoResumeTimeoutAfterUnsuitableOutputSuppressionMs)) { + unsuitableOutputPlaybackSuppressionStartRealtimeMs = C.TIME_UNSET; + player.play(); } } diff --git a/libraries/ui/src/test/java/androidx/media3/ui/WearUnsuitableOutputPlaybackSuppressionResolverListenerTest.java b/libraries/ui/src/test/java/androidx/media3/ui/WearUnsuitableOutputPlaybackSuppressionResolverListenerTest.java index b4f80e3ffc..d7e4f86de0 100644 --- a/libraries/ui/src/test/java/androidx/media3/ui/WearUnsuitableOutputPlaybackSuppressionResolverListenerTest.java +++ b/libraries/ui/src/test/java/androidx/media3/ui/WearUnsuitableOutputPlaybackSuppressionResolverListenerTest.java @@ -15,9 +15,11 @@ */ package androidx.media3.ui; +import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.runUntilPlayWhenReady; 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 java.util.Arrays.stream; import static org.robolectric.Shadows.shadowOf; import android.app.Application; @@ -34,11 +36,14 @@ 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.common.Player.PlayWhenReadyChangeReason; +import androidx.media3.test.utils.FakeClock; 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.time.Duration; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; @@ -63,6 +68,7 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { 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 static final long TEST_TIME_OUT_MS = Duration.ofMinutes(10).toMillis(); private ShadowPackageManager shadowPackageManager; private ShadowApplication shadowApplication; @@ -74,9 +80,6 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { new TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()) .setSuppressPlaybackOnUnsuitableOutput(true) .build(); - testPlayer.addListener( - new WearUnsuitableOutputPlaybackSuppressionResolverListener( - ApplicationProvider.getApplicationContext())); shadowApplication = shadowOf((Application) ApplicationProvider.getApplicationContext()); shadowPackageManager = shadowOf(ApplicationProvider.getApplicationContext().getPackageManager()); @@ -88,13 +91,12 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { } /** - * 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 end-to-end flow from launch of output switcher to playback getting resumed when the + * playback is suppressed and then unsuppressed. */ @Test - public void - playEventWithPlaybackSuppressionWhenSystemOutputSwitcherPresent_shouldLaunchOutputSwitcher() - throws TimeoutException { + public void playbackSuppressionFollowedByResolution_shouldLaunchOSwAndChangePlayerStateToPlaying() + throws TimeoutException { shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ true); registerFakeActivity( OUTPUT_SWITCHER_INTENT_ACTION_NAME, @@ -102,12 +104,27 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME, ApplicationInfo.FLAG_SYSTEM); setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + testPlayer.addListener( + new WearUnsuitableOutputPlaybackSuppressionResolverListener( + ApplicationProvider.getApplicationContext())); testPlayer.setMediaItem( MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); testPlayer.prepare(); + List playWhenReadyChangeSequence = new ArrayList<>(); + testPlayer.addListener( + new Player.Listener() { + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { + playWhenReadyChangeSequence.add(playWhenReady); + } + }); testPlayer.play(); runUntilPlaybackState(testPlayer, Player.STATE_READY); + addConnectedAudioOutput( + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, /* notifyAudioDeviceCallbacks= */ true); + runUntilPlayWhenReady(testPlayer, /* expectedPlayWhenReady= */ true); Intent intentTriggered = shadowApplication.getNextStartedActivity(); assertThat(intentTriggered).isNotNull(); @@ -115,6 +132,8 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { assertThat(intentTriggered) .hasComponent( FAKE_SYSTEM_OUTPUT_SWITCHER_PACKAGE_NAME, FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME); + assertThat(playWhenReadyChangeSequence).containsExactly(true, false, true); + assertThat(testPlayer.isPlaying()).isTrue(); } /** @@ -131,6 +150,9 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME, ApplicationInfo.FLAG_UPDATED_SYSTEM_APP); setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + testPlayer.addListener( + new WearUnsuitableOutputPlaybackSuppressionResolverListener( + ApplicationProvider.getApplicationContext())); testPlayer.setMediaItem( MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); testPlayer.prepare(); @@ -166,6 +188,9 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { "com.fake.userinstalled.outputswitcher.OutputSwitcherActivity", /* applicationFlags= */ 0); setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + testPlayer.addListener( + new WearUnsuitableOutputPlaybackSuppressionResolverListener( + ApplicationProvider.getApplicationContext())); testPlayer.setMediaItem( MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); testPlayer.prepare(); @@ -196,6 +221,9 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME, ApplicationInfo.FLAG_SYSTEM); setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + testPlayer.addListener( + new WearUnsuitableOutputPlaybackSuppressionResolverListener( + ApplicationProvider.getApplicationContext())); testPlayer.setMediaItem( MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); testPlayer.prepare(); @@ -236,6 +264,9 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { FAKE_SYSTEM_BT_SETTINGS_CLASS_NAME, ApplicationInfo.FLAG_SYSTEM); setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + testPlayer.addListener( + new WearUnsuitableOutputPlaybackSuppressionResolverListener( + ApplicationProvider.getApplicationContext())); testPlayer.setMediaItem( MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); testPlayer.prepare(); @@ -265,6 +296,9 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { FAKE_SYSTEM_BT_SETTINGS_CLASS_NAME, ApplicationInfo.FLAG_SYSTEM); setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + testPlayer.addListener( + new WearUnsuitableOutputPlaybackSuppressionResolverListener( + ApplicationProvider.getApplicationContext())); testPlayer.setMediaItem( MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); testPlayer.prepare(); @@ -298,6 +332,9 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { FAKE_SYSTEM_BT_SETTINGS_CLASS_NAME, ApplicationInfo.FLAG_SYSTEM); setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + testPlayer.addListener( + new WearUnsuitableOutputPlaybackSuppressionResolverListener( + ApplicationProvider.getApplicationContext())); testPlayer.setMediaItem( MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); testPlayer.prepare(); @@ -333,6 +370,9 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { "com.fake.userinstalled.btsettings.BluetoothSettingsActivity", /* applicationFlags= */ 0); setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + testPlayer.addListener( + new WearUnsuitableOutputPlaybackSuppressionResolverListener( + ApplicationProvider.getApplicationContext())); testPlayer.setMediaItem( MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); testPlayer.prepare(); @@ -364,6 +404,9 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { ApplicationInfo.FLAG_SYSTEM); setupConnectedAudioOutput( AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP); + testPlayer.addListener( + new WearUnsuitableOutputPlaybackSuppressionResolverListener( + ApplicationProvider.getApplicationContext())); testPlayer.setMediaItem( MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); testPlayer.prepare(); @@ -374,6 +417,43 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { assertThat(shadowApplication.getNextStartedActivity()).isNull(); } + /** + * Test for no launch of any system media output switching dialog app when playback is suppressed + * due to removal of all suitable audio outputs in mid of an ongoing playback. + */ + @Test + public void + playbackSuppressionDuringOngoingPlayback_shouldOnlyPauseButNotLaunchEitherOSwOrBTSettings() + 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.addListener( + new WearUnsuitableOutputPlaybackSuppressionResolverListener( + ApplicationProvider.getApplicationContext())); + testPlayer.setMediaItem( + MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); + testPlayer.prepare(); + testPlayer.play(); + runUntilPlaybackState(testPlayer, Player.STATE_READY); + + removeConnectedAudioOutput(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP); + runUntilPlayWhenReady(testPlayer, /* expectedPlayWhenReady= */ false); + + assertThat(shadowApplication.getNextStartedActivity()).isNull(); + assertThat(testPlayer.isPlaying()).isFalse(); + } + /** Test for pause on the Player when the playback is suppressed due to unsuitable output. */ @Test public void playEventWithSuppressedPlaybackCondition_shouldCallPauseOnPlayer() @@ -385,12 +465,15 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { FAKE_SYSTEM_OUTPUT_SWITCHER_CLASS_NAME, ApplicationInfo.FLAG_SYSTEM); setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + testPlayer.addListener( + new WearUnsuitableOutputPlaybackSuppressionResolverListener( + ApplicationProvider.getApplicationContext())); testPlayer.setMediaItem( MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); testPlayer.prepare(); AtomicBoolean isPlaybackPaused = new AtomicBoolean(false); testPlayer.addListener( - new Listener() { + new Player.Listener() { @Override public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { if (!playWhenReady) { @@ -419,12 +502,15 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { ApplicationInfo.FLAG_SYSTEM); setupConnectedAudioOutput( AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP); + testPlayer.addListener( + new WearUnsuitableOutputPlaybackSuppressionResolverListener( + ApplicationProvider.getApplicationContext())); testPlayer.setMediaItem( MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); testPlayer.prepare(); AtomicBoolean isPlaybackPaused = new AtomicBoolean(false); testPlayer.addListener( - new Listener() { + new Player.Listener() { @Override public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { if (!playWhenReady) { @@ -439,6 +525,95 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { assertThat(isPlaybackPaused.get()).isFalse(); } + /** + * Test to ensure player is not playing when the playback suppression due to unsuitable output is + * removed after the default timeout. + */ + @Test + public void + playbackSuppressionChangeToNoneAfterDefaultTimeout_shouldNotChangePlaybackStateToPlaying() + throws TimeoutException { + shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ true); + setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + FakeClock fakeClock = new FakeClock(/* isAutoAdvancing= */ true); + testPlayer.addListener( + new WearUnsuitableOutputPlaybackSuppressionResolverListener( + ApplicationProvider.getApplicationContext(), + WearUnsuitableOutputPlaybackSuppressionResolverListener + .DEFAULT_PLAYBACK_SUPPRESSION_AUTO_RESUME_TIMEOUT_MS, + fakeClock)); + testPlayer.setMediaItem( + MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); + testPlayer.prepare(); + testPlayer.play(); + runUntilPlaybackState(testPlayer, Player.STATE_READY); + + fakeClock.advanceTime( + WearUnsuitableOutputPlaybackSuppressionResolverListener + .DEFAULT_PLAYBACK_SUPPRESSION_AUTO_RESUME_TIMEOUT_MS + * 2); + addConnectedAudioOutput( + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, /* notifyAudioDeviceCallbacks= */ true); + runUntilPlayWhenReady(testPlayer, /* expectedPlayWhenReady= */ false); + + assertThat(testPlayer.isPlaying()).isFalse(); + } + + /** + * Test to ensure player is playing when the playback suppression due to unsuitable output is + * removed within the set timeout. + */ + @Test + public void playbackSuppressionChangeToNoneWithinSetTimeout_shouldChangePlaybackStateToPlaying() + throws TimeoutException { + shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ true); + setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + FakeClock fakeClock = new FakeClock(/* isAutoAdvancing= */ true); + testPlayer.addListener( + new WearUnsuitableOutputPlaybackSuppressionResolverListener( + ApplicationProvider.getApplicationContext(), TEST_TIME_OUT_MS, fakeClock)); + testPlayer.setMediaItem( + MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); + testPlayer.prepare(); + testPlayer.play(); + runUntilPlaybackState(testPlayer, Player.STATE_READY); + + fakeClock.advanceTime(TEST_TIME_OUT_MS / 2); + addConnectedAudioOutput( + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, /* notifyAudioDeviceCallbacks= */ true); + runUntilPlayWhenReady(testPlayer, /* expectedPlayWhenReady= */ true); + + assertThat(testPlayer.isPlaying()).isTrue(); + } + + /** + * Test to ensure player is not playing when the playback suppression due to unsuitable output is + * removed after the set timeout. + */ + @Test + public void + playbackSuppressionChangeToNoneAfterSetTimeout_shouldNotChangeFinalPlaybackStateToPlaying() + throws TimeoutException { + shadowPackageManager.setSystemFeature(PackageManager.FEATURE_WATCH, /* supported= */ true); + setupConnectedAudioOutput(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); + FakeClock fakeClock = new FakeClock(/* isAutoAdvancing= */ true); + testPlayer.addListener( + new WearUnsuitableOutputPlaybackSuppressionResolverListener( + ApplicationProvider.getApplicationContext(), TEST_TIME_OUT_MS, fakeClock)); + testPlayer.setMediaItem( + MediaItem.fromUri("asset:///media/mp4/sample_with_increasing_timestamps_360p.mp4")); + testPlayer.prepare(); + testPlayer.play(); + runUntilPlaybackState(testPlayer, Player.STATE_READY); + + fakeClock.advanceTime(TEST_TIME_OUT_MS * 2); + addConnectedAudioOutput( + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, /* notifyAudioDeviceCallbacks= */ true); + runUntilPlayWhenReady(testPlayer, /* expectedPlayWhenReady= */ false); + + assertThat(testPlayer.isPlaying()).isFalse(); + } + private void registerFakeActivity( String fakeActionName, String fakePackageName, String fakeClassName, int applicationFlags) { ComponentName fakeComponentName = new ComponentName(fakePackageName, fakeClassName); @@ -465,4 +640,24 @@ public class WearUnsuitableOutputPlaybackSuppressionResolverListenerTest { } shadowAudioManager.setOutputDevices(deviceListBuilder.build()); } + + private void addConnectedAudioOutput(int deviceTypes, boolean notifyAudioDeviceCallbacks) { + ShadowAudioManager shadowAudioManager = + shadowOf(ApplicationProvider.getApplicationContext().getSystemService(AudioManager.class)); + shadowAudioManager.addOutputDevice( + AudioDeviceInfoBuilder.newBuilder().setType(deviceTypes).build(), + notifyAudioDeviceCallbacks); + } + + private void removeConnectedAudioOutput(int deviceType) { + ShadowAudioManager shadowAudioManager = + shadowOf(ApplicationProvider.getApplicationContext().getSystemService(AudioManager.class)); + stream(shadowAudioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) + .filter(audioDeviceInfo -> deviceType == audioDeviceInfo.getType()) + .findFirst() + .ifPresent( + filteredAudioDeviceInfo -> + shadowAudioManager.removeOutputDevice( + filteredAudioDeviceInfo, /* notifyAudioDeviceCallbacks= */ true)); + } }