Skip to content
4 changes: 4 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions libraries/session/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand All @@ -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) {
Expand All @@ -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();
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<TestServiceWithPlaybackResumption> serviceController =
Robolectric.buildService(TestServiceWithPlaybackResumption.class, playIntent);
TestServiceWithPlaybackResumption service = serviceController.create().get();
Expand Down Expand Up @@ -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;
}
}
}
1 change: 1 addition & 0 deletions libraries/test_session_current/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {

Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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();
}
Expand Down