diff --git a/RELEASENOTES.md b/RELEASENOTES.md index cc04bd87822..ea3efa08519 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -219,6 +219,10 @@ * Implemented `onAudioSessionIdChanged` to notify media controllers when an audio session ID is set by the session ([#244](https://github.com/androidx/media/issues/244)). + * Fix bug where `KEYCODE_HEADSETHOOK` did not start the player upon and + media key event `Intent` arriving in `onStartCommand()`. This is fixed + by handling 'KEYCODE_HEADSETHOOK' just like `KEYCODE_MEDIA_PLAY_PAUSE` + ([#2816](https://github.com/androidx/media/pull/2816)). * UI: * Add `ProgressStateWithTickInterval` class and the corresponding `rememberProgressStateWithTickInterval` Composable to diff --git a/libraries/session/build.gradle b/libraries/session/build.gradle index 8642e060223..187a4f00dfa 100644 --- a/libraries/session/build.gradle +++ b/libraries/session/build.gradle @@ -48,6 +48,7 @@ dependencies { testImplementation project(modulePrefix + 'test-utils-robolectric') testImplementation project(modulePrefix + 'lib-exoplayer') testImplementation 'org.robolectric:robolectric:' + robolectricVersion + testImplementation 'com.google.testparameterinjector:test-parameter-injector:' + testParameterInjectorVersion } ext { diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java index b798d3a460d..1a435a1384b 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java @@ -15,6 +15,7 @@ */ package androidx.media3.session; +import static android.view.KeyEvent.KEYCODE_HEADSETHOOK; import static android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD; import static android.view.KeyEvent.KEYCODE_MEDIA_NEXT; import static android.view.KeyEvent.KEYCODE_MEDIA_PAUSE; @@ -1431,6 +1432,7 @@ private void handleAvailablePlayerCommandsChanged(Player.Commands availableComma if (keyEvent.getAction() != KeyEvent.ACTION_DOWN) { switch (keyEvent.getKeyCode()) { case KEYCODE_MEDIA_PLAY_PAUSE: + case KEYCODE_HEADSETHOOK: case KEYCODE_MEDIA_PLAY: case KEYCODE_MEDIA_PAUSE: case KEYCODE_MEDIA_NEXT: @@ -1453,8 +1455,8 @@ private void handleAvailablePlayerCommandsChanged(Player.Commands availableComma boolean isTvApp = context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK); boolean doubleTapCompleted = false; switch (keyCode) { - case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: - case KeyEvent.KEYCODE_HEADSETHOOK: + case KEYCODE_MEDIA_PLAY_PAUSE: + case KEYCODE_HEADSETHOOK: if (isTvApp || callerInfo.getControllerVersion() != ControllerInfo.LEGACY_CONTROLLER_VERSION || keyEvent.getRepeatCount() != 0) { @@ -1479,7 +1481,7 @@ private void handleAvailablePlayerCommandsChanged(Player.Commands availableComma } if (!isMediaNotificationControllerConnected()) { - if ((keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE || keyCode == KeyEvent.KEYCODE_HEADSETHOOK) + if ((keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE || keyCode == KEYCODE_HEADSETHOOK) && doubleTapCompleted) { // Double tap completion for legacy when media notification controller is disabled. sessionLegacyStub.onSkipToNext(); @@ -1505,12 +1507,13 @@ private boolean applyMediaButtonKeyEvent( ControllerInfo controllerInfo = checkNotNull(instance.getMediaNotificationControllerInfo()); Runnable command; int keyCode = keyEvent.getKeyCode(); - if ((keyCode == KEYCODE_MEDIA_PLAY_PAUSE || keyCode == KeyEvent.KEYCODE_HEADSETHOOK) + if ((keyCode == KEYCODE_MEDIA_PLAY_PAUSE || keyCode == KEYCODE_HEADSETHOOK) && doubleTapCompleted) { keyCode = KEYCODE_MEDIA_NEXT; } switch (keyCode) { case KEYCODE_MEDIA_PLAY_PAUSE: + case KEYCODE_HEADSETHOOK: command = getPlayerWrapper().getPlayWhenReady() ? () -> sessionStub.pauseForControllerInfo(controllerInfo, UNKNOWN_SEQUENCE_NUMBER) diff --git a/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java b/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java index f53a23a7f69..470da4a5878 100644 --- a/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java +++ b/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java @@ -36,10 +36,10 @@ import androidx.media3.exoplayer.ExoPlayer; 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 com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import com.google.testing.junit.testparameterinjector.TestParameter; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -51,10 +51,11 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestParameterInjector; import org.robolectric.android.controller.ServiceController; import org.robolectric.shadows.ShadowLooper; -@RunWith(AndroidJUnit4.class) +@RunWith(RobolectricTestParameterInjector.class) public class MediaSessionServiceTest { private static final int TIMEOUT_MS = 500; @@ -748,11 +749,12 @@ public void pause() { } @Test - public void onStartCommand_playbackResumption_calledByMediaNotificationController() + public void onStartCommand_playbackResumption_calledByMediaNotificationController( + @TestParameter PlayPauseEvent playPauseEvent) throws InterruptedException, ExecutionException, TimeoutException { Intent playIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); playIntent.putExtra( - Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY)); + Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, playPauseEvent.keyCode)); ServiceController serviceController = Robolectric.buildService(TestServiceWithPlaybackResumption.class, playIntent); TestServiceWithPlaybackResumption service = serviceController.create().get(); @@ -1011,4 +1013,16 @@ public void onDestroy() { super.onDestroy(); } } + + private enum PlayPauseEvent { + MEDIA_PLAY_PAUSE(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE), + MEDIA_PLAY(KeyEvent.KEYCODE_MEDIA_PLAY), + HEADSETHOOK(KeyEvent.KEYCODE_HEADSETHOOK); + + final int keyCode; + + PlayPauseEvent(int keyCode) { + this.keyCode = keyCode; + } + } } diff --git a/libraries/test_session_current/build.gradle b/libraries/test_session_current/build.gradle index 9feae32e90e..3d98756fc9b 100644 --- a/libraries/test_session_current/build.gradle +++ b/libraries/test_session_current/build.gradle @@ -44,6 +44,7 @@ dependencies { implementation project(modulePrefix + 'test-data') androidTestImplementation project(modulePrefix + 'lib-exoplayer') androidTestImplementation project(modulePrefix + 'test-utils') + androidTestImplementation 'com.google.testparameterinjector:test-parameter-injector:' + testParameterInjectorVersion androidTestImplementation 'androidx.test.ext:junit:' + androidxTestJUnitVersion androidTestImplementation 'androidx.test.ext:truth:' + androidxTestTruthVersion androidTestImplementation 'androidx.test:core:' + androidxTestCoreVersion diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionKeyEventTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionKeyEventTest.java index 55a3ee85fa1..cc2ad7a7edd 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionKeyEventTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionKeyEventTest.java @@ -42,9 +42,10 @@ import androidx.media3.test.session.common.R; import androidx.media3.test.session.common.TestHandler; import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; import com.google.common.collect.ImmutableList; +import com.google.testing.junit.testparameterinjector.TestParameter; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -59,7 +60,7 @@ * Tests for key event handling of {@link MediaSession}. In order to get the media key events, the * player state is set to 'Playing' before every test method. */ -@RunWith(AndroidJUnit4.class) +@RunWith(TestParameterInjector.class) @LargeTest public class MediaSessionKeyEventTest { @@ -271,65 +272,80 @@ public void stopKeyEvent() throws Exception { } @Test - public void playPauseKeyEvent_paused_play() throws Exception { + public void playPauseKeyEvent_paused_play(@TestParameter PlayPauseEvent playPauseEvent) + throws Exception { // We don't receive media key events when we are not playing on API < 26, so we can't test this // case as it's not supported. assumeTrue(SDK_INT >= 26); - handler.postAndSync( () -> { player.playbackState = Player.STATE_READY; }); - dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, false); + dispatchMediaKeyEvent(playPauseEvent.keyCode, /* doubleTap= */ false); player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS); } @Test - public void playPauseKeyEvent_fromIdle_prepareAndPlay() throws Exception { + public void playPauseKeyEvent_fromIdle_prepareAndPlay( + @TestParameter PlayPauseEvent playPauseEvent) throws Exception { // We don't receive media key events when we are not playing on API < 26, so we can't test this // case as it's not supported. assumeTrue(SDK_INT >= 26); - handler.postAndSync( () -> { player.playbackState = Player.STATE_IDLE; }); - dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, false); + dispatchMediaKeyEvent(playPauseEvent.keyCode, /* doubleTap= */ false); player.awaitMethodCalled(MockPlayer.METHOD_PREPARE, TIMEOUT_MS); player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS); } @Test - public void playPauseKeyEvent_playWhenReadyAndEnded_seekAndPlay() throws Exception { + public void playPauseKeyEvent_playWhenReadyAndEnded_seekAndPlay( + @TestParameter PlayPauseEvent playPauseEvent) throws Exception { // We don't receive media key events when we are not playing on API < 26, so we can't test this // case as it's not supported. assumeTrue(SDK_INT >= 26); - handler.postAndSync( () -> { player.playWhenReady = true; player.playbackState = STATE_ENDED; }); - dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, false); + dispatchMediaKeyEvent(playPauseEvent.keyCode, /* doubleTap= */ false); player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_DEFAULT_POSITION, TIMEOUT_MS); player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS); } @Test - public void playPauseKeyEvent_playing_pause() throws Exception { + public void playPauseKeyEvent_playing_pause() + throws Exception { + handler.postAndSync( + () -> { + player.playWhenReady = true; + player.playbackState = Player.STATE_READY; + }); + + dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, /* doubleTap= */ false); + + player.awaitMethodCalled(MockPlayer.METHOD_PAUSE, TIMEOUT_MS); + } + + @Test + public void headsetHookKeyEvent_playing_pause() + throws Exception { handler.postAndSync( () -> { player.playWhenReady = true; player.playbackState = Player.STATE_READY; }); - dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, false); + dispatchMediaKeyEvent(KeyEvent.KEYCODE_HEADSETHOOK, /* doubleTap= */ false); player.awaitMethodCalled(MockPlayer.METHOD_PAUSE, TIMEOUT_MS); } @@ -348,7 +364,7 @@ public void playPauseKeyEvent_doubleTapOnPlayPause_seekNext() throws Exception { } @Test - public void playPauseKeyEvent_doubleTapOnHeadsetHook_seekNext() throws Exception { + public void headsetHookKeyEvent_doubleTapOnPlayPause_seekNext() throws Exception { handler.postAndSync( () -> { player.playWhenReady = true; @@ -516,4 +532,16 @@ public void seekBack() { super.seekBack(); } } + + private enum PlayPauseEvent { + MEDIA_PLAY_PAUSE(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE), + MEDIA_PLAY(KeyEvent.KEYCODE_MEDIA_PLAY), + HEADSETHOOK(KeyEvent.KEYCODE_HEADSETHOOK); + + final int keyCode; + + PlayPauseEvent(int keyCode) { + this.keyCode = keyCode; + } + } } diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionTest.java index c53329d3d07..190fa157010 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionTest.java @@ -16,6 +16,7 @@ package androidx.media3.session; import static android.os.Build.VERSION.SDK_INT; +import static android.view.KeyEvent.KEYCODE_HEADSETHOOK; import static android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD; import static android.view.KeyEvent.KEYCODE_MEDIA_NEXT; import static android.view.KeyEvent.KEYCODE_MEDIA_PAUSE; @@ -634,6 +635,14 @@ controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_PREVIOUS))) impl.onMediaButtonEvent( controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_STOP))) .isTrue(); + assertThat( + impl.onMediaButtonEvent( + controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_PLAY_PAUSE))) + .isTrue(); + assertThat( + impl.onMediaButtonEvent( + controllerInfo, getMediaButtonIntent(KEYCODE_HEADSETHOOK))) + .isTrue(); }); player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS); @@ -643,7 +652,7 @@ controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_STOP))) player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_NEXT, TIMEOUT_MS); player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_PREVIOUS, TIMEOUT_MS); player.awaitMethodCalled(MockPlayer.METHOD_STOP, TIMEOUT_MS); - assertThat(callerCollectorPlayer.callingControllers).hasSize(7); + assertThat(callerCollectorPlayer.callingControllers).hasSize(9); for (ControllerInfo controllerInfo : callerCollectorPlayer.callingControllers) { assertThat(session.get().isMediaNotificationController(controllerInfo)).isTrue(); }