Skip to content

session: fix a deadlock due to onPlayRequested() #2256

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1100,20 +1100,23 @@ protected MediaSessionServiceLegacyStub getLegacyBrowserService() {
});
}

/* package */ boolean onPlayRequested() {
/* package */ ListenableFuture<Boolean> onPlayRequested() {
if (Looper.myLooper() != Looper.getMainLooper()) {
SettableFuture<Boolean> playRequested = SettableFuture.create();
mainHandler.post(() -> playRequested.set(onPlayRequested()));
try {
return playRequested.get();
} catch (InterruptedException | ExecutionException e) {
throw new IllegalStateException(e);
}
mainHandler.post(
() -> {
try {
playRequested.set(onPlayRequested().get());
} catch (ExecutionException | InterruptedException e) {
playRequested.setException(new IllegalStateException(e));
}
});
return playRequested;
}
if (this.mediaSessionListener != null) {
return this.mediaSessionListener.onPlayRequested(instance);
return Futures.immediateFuture(this.mediaSessionListener.onPlayRequested(instance));
}
return true;
return Futures.immediateFuture(true);
}

/**
Expand All @@ -1124,84 +1127,103 @@ protected MediaSessionServiceLegacyStub getLegacyBrowserService() {
*
* @param controller The controller requesting to play.
*/
/* package */ void handleMediaControllerPlayRequest(
/* package */ ListenableFuture<SessionResult> handleMediaControllerPlayRequest(
ControllerInfo controller, boolean callOnPlayerInteractionFinished) {
if (!onPlayRequested()) {
// Request denied, e.g. due to missing foreground service abilities.
return;
}
boolean hasCurrentMediaItem =
playerWrapper.isCommandAvailable(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)
&& playerWrapper.getCurrentMediaItem() != null;
boolean canAddMediaItems =
playerWrapper.isCommandAvailable(COMMAND_SET_MEDIA_ITEM)
|| playerWrapper.isCommandAvailable(COMMAND_CHANGE_MEDIA_ITEMS);
ControllerInfo controllerForRequest = resolveControllerInfoForCallback(controller);
Player.Commands playCommand =
new Player.Commands.Builder().add(Player.COMMAND_PLAY_PAUSE).build();
if (hasCurrentMediaItem || !canAddMediaItems) {
// No playback resumption needed or possible.
if (!hasCurrentMediaItem) {
Log.w(
TAG,
"Play requested without current MediaItem, but playback resumption prevented by"
+ " missing available commands");
}
Util.handlePlayButtonAction(playerWrapper);
if (callOnPlayerInteractionFinished) {
onPlayerInteractionFinishedOnHandler(controllerForRequest, playCommand);
}
} else {
@Nullable
ListenableFuture<MediaItemsWithStartPosition> future =
checkNotNull(
callback.onPlaybackResumption(
instance, controllerForRequest, /* isForPlayback= */ true),
"Callback.onPlaybackResumption must return a non-null future");
Futures.addCallback(
future,
new FutureCallback<MediaItemsWithStartPosition>() {
@Override
public void onSuccess(MediaItemsWithStartPosition mediaItemsWithStartPosition) {
callWithControllerForCurrentRequestSet(
controllerForRequest,
() -> {
MediaUtils.setMediaItemsWithStartIndexAndPosition(
playerWrapper, mediaItemsWithStartPosition);
Util.handlePlayButtonAction(playerWrapper);
if (callOnPlayerInteractionFinished) {
onPlayerInteractionFinishedOnHandler(controllerForRequest, playCommand);
}
})
.run();
SettableFuture<SessionResult> sessionFuture = SettableFuture.create();
ListenableFuture<Boolean> playRequestedFuture = onPlayRequested();
playRequestedFuture.addListener(
() -> {
boolean playRequested;
try {
playRequested = playRequestedFuture.get();
} catch (ExecutionException | InterruptedException e) {
sessionFuture.setException(new IllegalStateException(e));
return;
}
if (!playRequested) {
// Request denied, e.g. due to missing foreground service abilities.
sessionFuture.set(new SessionResult(SessionResult.RESULT_ERROR_UNKNOWN));
return;
}
boolean hasCurrentMediaItem =
playerWrapper.isCommandAvailable(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)
&& playerWrapper.getCurrentMediaItem() != null;
boolean canAddMediaItems =
playerWrapper.isCommandAvailable(COMMAND_SET_MEDIA_ITEM)
|| playerWrapper.isCommandAvailable(COMMAND_CHANGE_MEDIA_ITEMS);
ControllerInfo controllerForRequest = resolveControllerInfoForCallback(controller);
Player.Commands playCommand =
new Player.Commands.Builder().add(Player.COMMAND_PLAY_PAUSE).build();
if (hasCurrentMediaItem || !canAddMediaItems) {
// No playback resumption needed or possible.
if (!hasCurrentMediaItem) {
Log.w(
TAG,
"Play requested without current MediaItem, but playback resumption prevented by"
+ " missing available commands");
}

@Override
public void onFailure(Throwable t) {
if (t instanceof UnsupportedOperationException) {
Log.w(
TAG,
"UnsupportedOperationException: Make sure to implement"
+ " MediaSession.Callback.onPlaybackResumption() if you add a"
+ " media button receiver to your manifest or if you implement the recent"
+ " media item contract with your MediaLibraryService.",
t);
} else {
Log.e(
TAG,
"Failure calling MediaSession.Callback.onPlaybackResumption(): "
+ t.getMessage(),
t);
}
// Play as requested even if playback resumption fails.
Util.handlePlayButtonAction(playerWrapper);
if (callOnPlayerInteractionFinished) {
onPlayerInteractionFinishedOnHandler(controllerForRequest, playCommand);
}
Util.handlePlayButtonAction(playerWrapper);
sessionFuture.set(new SessionResult(SessionResult.RESULT_SUCCESS));
if (callOnPlayerInteractionFinished) {
onPlayerInteractionFinishedOnHandler(controllerForRequest, playCommand);
}
},
this::postOrRunOnApplicationHandler);
}
} else {
@Nullable
ListenableFuture<MediaItemsWithStartPosition> future =
checkNotNull(
callback.onPlaybackResumption(
instance, controllerForRequest, /* isForPlayback= */ true),
"Callback.onPlaybackResumption must return a non-null future");
Futures.addCallback(
future,
new FutureCallback<MediaItemsWithStartPosition>() {
@Override
public void onSuccess(MediaItemsWithStartPosition mediaItemsWithStartPosition) {
callWithControllerForCurrentRequestSet(
controllerForRequest,
() -> {
MediaUtils.setMediaItemsWithStartIndexAndPosition(
playerWrapper, mediaItemsWithStartPosition);
Util.handlePlayButtonAction(playerWrapper);
sessionFuture.set(new SessionResult(SessionResult.RESULT_SUCCESS));
if (callOnPlayerInteractionFinished) {
onPlayerInteractionFinishedOnHandler(
controllerForRequest, playCommand);
}
})
.run();
}

@Override
public void onFailure(Throwable t) {
if (t instanceof UnsupportedOperationException) {
Log.w(
TAG,
"UnsupportedOperationException: Make sure to implement"
+ " MediaSession.Callback.onPlaybackResumption() if you add a media"
+ " button receiver to your manifest or if you implement the recent"
+ " media item contract with your MediaLibraryService.",
t);
} else {
Log.e(
TAG,
"Failure calling MediaSession.Callback.onPlaybackResumption(): "
+ t.getMessage(),
t);
}
// Play as requested even if playback resumption fails.
Util.handlePlayButtonAction(playerWrapper);
sessionFuture.set(new SessionResult(SessionResult.RESULT_SUCCESS));
if (callOnPlayerInteractionFinished) {
onPlayerInteractionFinishedOnHandler(controllerForRequest, playCommand);
}
}
},
this::postOrRunOnApplicationHandler);
}
},
this::postOrRunOnApplicationHandler);
return sessionFuture;
}

/* package */ void triggerPlayerInfoUpdate() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -607,9 +607,27 @@ public void onPrepareFromUri(@Nullable Uri mediaUri, @Nullable Bundle extras) {
public void onPlay() {
dispatchSessionTaskWithPlayerCommand(
COMMAND_PLAY_PAUSE,
controller ->
sessionImpl.handleMediaControllerPlayRequest(
controller, /* callOnPlayerInteractionFinished= */ true),
controller -> {
ListenableFuture<SessionResult> resultFuture =
sessionImpl.handleMediaControllerPlayRequest(
controller, /* callOnPlayerInteractionFinished= */ true);
Futures.addCallback(
resultFuture,
new FutureCallback<SessionResult>() {
@Override
public void onSuccess(SessionResult result) {
if (result.resultCode != RESULT_SUCCESS) {
Log.w(TAG, "onPlay() failed: " + result + " (from: " + controller + ")");
}
}

@Override
public void onFailure(Throwable t) {
Log.e(TAG, "Unexpected exception in onPlay() of " + controller, t);
}
},
MoreExecutors.directExecutor());
},
sessionCompat.getCurrentControllerInfo(),
/* callOnPlayerInteractionFinished= */ false);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -749,15 +749,10 @@ public void playForControllerInfo(ControllerInfo controller, int sequenceNumber)
controller,
sequenceNumber,
COMMAND_PLAY_PAUSE,
sendSessionResultSuccess(
player -> {
@Nullable MediaSessionImpl impl = sessionImpl.get();
if (impl == null || impl.isReleased()) {
return;
}
impl.handleMediaControllerPlayRequest(
controller, /* callOnPlayerInteractionFinished= */ false);
}));
sendSessionResultWhenReady(
(session, theController, sequenceId) ->
session.handleMediaControllerPlayRequest(
theController, /* callOnPlayerInteractionFinished= */ false)));
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,15 @@
import android.content.Context;
import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import androidx.core.content.ContextCompat;
import androidx.media3.common.ForwardingPlayer;
import androidx.media3.common.MediaItem;
import androidx.media3.common.PlaybackParameters;
import androidx.media3.common.Player;
import androidx.media3.common.util.ConditionVariable;
import androidx.media3.exoplayer.ExoPlayer;
Expand All @@ -47,6 +50,8 @@
import androidx.test.filters.MediumTest;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import java.util.ArrayList;
Expand Down Expand Up @@ -242,6 +247,70 @@ public ListenableFuture<List<MediaItem>> onAddMediaItems(
service.blockUntilAllControllersUnbind(TIMEOUT_MS);
}

@Test
public void onPlayRequested_doesNotCauseDeadlock_ifPlaybackThreadIsNotMain() throws Exception {
HandlerThread ht = new HandlerThread("MSSTest:PlaybackThread");
ht.start();
TestServiceRegistry testServiceRegistry = TestServiceRegistry.getInstance();
ConditionVariable playerToldToSpeedUp = new ConditionVariable();
AtomicReference<MediaController> mediaController = new AtomicReference<>();
AtomicReference<MediaSession> mediaSession = new AtomicReference<>();
testServiceRegistry.setOnGetSessionHandler(
controllerInfo -> {
MockMediaSessionService service =
(MockMediaSessionService) testServiceRegistry.getServiceInstance();
Player player = new ExoPlayer.Builder(service).setLooper(ht.getLooper()).build();
player.addListener(
new Player.Listener() {
@Override
public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) {
if (playbackParameters.speed == 2f) {
playerToldToSpeedUp.open();
}
}
});
mediaSession.set(new MediaSession.Builder(service, player).build());
return mediaSession.get();
});
TestHandler handler = new TestHandler(Looper.getMainLooper());
TestHandler bgHandler = new TestHandler(ht.getLooper());
handler.post(
() -> {
ListenableFuture<MediaController> controllerFuture =
new MediaController.Builder(context, token).buildAsync();
Futures.addCallback(
controllerFuture,
new FutureCallback<MediaController>() {
@Override
public void onSuccess(MediaController controller) {
controller.addMediaItem(new MediaItem.Builder().setMediaId("media_id").build());
controller.play();
bgHandler.post(
() ->
handler.post(
() -> {
controller.setPlaybackSpeed(2f);
mediaController.set(controller);
}));
}

@Override
public void onFailure(Throwable t) {
throw new RuntimeException(t);
}
},
ContextCompat.getMainExecutor(context));
});
playerToldToSpeedUp.block(TIMEOUT_MS);
if (!playerToldToSpeedUp.isOpen()) {
ht.interrupt(); // avoid deadlocking the test forever.
}
assertThat(playerToldToSpeedUp.isOpen()).isTrue();
handler.postAndSync(() -> mediaController.get().release());
mediaSession.get().release();
ht.quitSafely();
}

@Test
public void onCreate_withCustomLayout_correctSessionStateFromOnConnect() throws Exception {
SessionCommand command1 = new SessionCommand("command1", Bundle.EMPTY);
Expand Down