diff --git a/library/common/build.gradle b/library/common/build.gradle index d1d0d86f42..b5aec23700 100644 --- a/library/common/build.gradle +++ b/library/common/build.gradle @@ -37,6 +37,7 @@ dependencies { testImplementation 'com.google.truth:truth:' + truthVersion testImplementation 'com.squareup.okhttp3:mockwebserver:' + mockWebServerVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion + testImplementation project(modulePrefix + 'library-core') testImplementation project(modulePrefix + 'testutils') } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/ForwardingPlayer.java b/library/common/src/main/java/com/google/android/exoplayer2/ForwardingPlayer.java index e7aaafd689..eb78179752 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/ForwardingPlayer.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/ForwardingPlayer.java @@ -15,31 +15,74 @@ */ package com.google.android.exoplayer2; +import static com.google.android.exoplayer2.util.Assertions.checkState; + import android.os.Looper; import android.view.Surface; import android.view.SurfaceHolder; import android.view.SurfaceView; import android.view.TextureView; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.audio.AudioAttributes; import com.google.android.exoplayer2.device.DeviceInfo; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.util.Clock; +import com.google.android.exoplayer2.util.ListenerSet; import com.google.android.exoplayer2.video.VideoSize; import java.util.List; /** * A {@link Player} that forwards operations to another {@link Player}. Applications can use this * class to suppress or modify specific operations, by overriding the respective methods. + * + *

An application can {@link #setDisabledCommands disable available commands}. When the wrapped + * player advertises available commands, either with {@link Player#isCommandAvailable(int)} or with + * {@link Listener#onAvailableCommandsChanged}, the disabled commands will be filtered out. */ public class ForwardingPlayer extends BasePlayer { private final Player player; + private final Clock clock; + @Nullable private ForwardingListener forwardingListener; + + private Commands disabledCommands; + @Nullable private Commands unfilteredCommands; + @Nullable private Commands filteredCommands; /** Creates a new instance that forwards all operations to {@code player}. */ public ForwardingPlayer(Player player) { + this(player, Clock.DEFAULT); + } + + @VisibleForTesting + /* package */ ForwardingPlayer(Player player, Clock clock) { this.player = player; + this.clock = clock; + this.disabledCommands = Commands.EMPTY; + } + + /** + * Sets the disabled {@link Commands}. + * + *

When querying for available commands with {@link #isCommandAvailable(int)}, or when the + * wrapped player advertises available commands with {@link Listener#isCommandAvailable}, disabled + * commands will be filtered out. + */ + public void setDisabledCommands(Commands commands) { + checkState(player.getApplicationLooper().equals(Looper.myLooper())); + disabledCommands = commands; + filteredCommands = null; + if (forwardingListener != null) { + forwardingListener.maybeAdvertiseAvailableCommands(); + } + } + + /** Returns the disabled commands. */ + public Commands getDisabledCommands() { + return disabledCommands; } @Override @@ -49,22 +92,34 @@ public class ForwardingPlayer extends BasePlayer { @Override public void addListener(EventListener listener) { - player.addListener(listener); + addListener(new EventListenerWrapper(listener)); } @Override public void addListener(Listener listener) { - player.addListener(listener); + if (forwardingListener == null) { + forwardingListener = new ForwardingListener(this); + } + if (!forwardingListener.isRegistered()) { + forwardingListener.registerTo(player); + } + forwardingListener.addListener(listener); } @Override public void removeListener(EventListener listener) { - player.removeListener(listener); + removeListener(new EventListenerWrapper(listener)); } @Override public void removeListener(Listener listener) { - player.removeListener(listener); + if (forwardingListener == null) { + return; + } + forwardingListener.removeListener(listener); + if (!forwardingListener.hasListeners()) { + forwardingListener.unregisterFrom(player); + } } @Override @@ -95,7 +150,12 @@ public class ForwardingPlayer extends BasePlayer { @Override public Commands getAvailableCommands() { - return player.getAvailableCommands(); + Commands commands = player.getAvailableCommands(); + if (filteredCommands == null || !commands.equals(unfilteredCommands)) { + filteredCommands = filterCommands(commands, disabledCommands); + unfilteredCommands = commands; + } + return filteredCommands; } @Override @@ -364,4 +424,474 @@ public class ForwardingPlayer extends BasePlayer { public void setDeviceMuted(boolean muted) { player.setDeviceMuted(muted); } + + /** + * Wraps a {@link Listener} and intercepts {@link EventListener#onAvailableCommandsChanged} in + * order to filter disabled commands. All other operations are forwarded to the wrapped {@link + * Listener}. + */ + private static class ForwardingListener implements Listener { + private final ForwardingPlayer player; + private final ListenerSet listeners; + private boolean registered; + private Commands lastReceivedCommands; + private Commands lastAdvertisedCommands; + + public ForwardingListener(ForwardingPlayer forwardingPlayer) { + this.player = forwardingPlayer; + listeners = + new ListenerSet<>( + forwardingPlayer.player.getApplicationLooper(), + forwardingPlayer.clock, + (listener, flags) -> listener.onEvents(forwardingPlayer, new Events(flags))); + lastReceivedCommands = Commands.EMPTY; + lastAdvertisedCommands = Commands.EMPTY; + } + + public void registerTo(Player player) { + checkState(!registered); + player.addListener(this); + lastReceivedCommands = player.getAvailableCommands(); + lastAdvertisedCommands = lastReceivedCommands; + registered = true; + } + + public void unregisterFrom(Player player) { + checkState(registered); + player.removeListener(this); + registered = false; + } + + public boolean isRegistered() { + return registered; + } + + public void addListener(Listener listener) { + listeners.add(listener); + } + + public void removeListener(Listener listener) { + listeners.remove(listener); + } + + public boolean hasListeners() { + return listeners.size() > 0; + } + + // VideoListener callbacks + @Override + public void onVideoSizeChanged(VideoSize videoSize) { + listeners.sendEvent(C.INDEX_UNSET, listener -> listener.onVideoSizeChanged(videoSize)); + } + + @Override + @SuppressWarnings("deprecation") // Forwarding to deprecated method. + public void onVideoSizeChanged( + int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { + listeners.sendEvent( + C.INDEX_UNSET, + listener -> + listener.onVideoSizeChanged( + width, height, unappliedRotationDegrees, pixelWidthHeightRatio)); + } + + @Override + public void onSurfaceSizeChanged(int width, int height) { + listeners.sendEvent(C.INDEX_UNSET, listener -> listener.onSurfaceSizeChanged(width, height)); + } + + @Override + public void onRenderedFirstFrame() { + listeners.sendEvent(C.INDEX_UNSET, Listener::onRenderedFirstFrame); + } + + // AudioListener callbacks + + @Override + public void onAudioSessionIdChanged(int audioSessionId) { + listeners.sendEvent( + C.INDEX_UNSET, listener -> listener.onAudioSessionIdChanged(audioSessionId)); + } + + @Override + public void onAudioAttributesChanged(AudioAttributes audioAttributes) { + listeners.sendEvent( + C.INDEX_UNSET, listener -> listener.onAudioAttributesChanged(audioAttributes)); + } + + @Override + public void onVolumeChanged(float volume) { + listeners.sendEvent(C.INDEX_UNSET, listener -> listener.onVolumeChanged(volume)); + } + + @Override + public void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled) { + listeners.sendEvent( + C.INDEX_UNSET, listener -> listener.onSkipSilenceEnabledChanged(skipSilenceEnabled)); + } + + // TextOutput callbacks + + @Override + public void onCues(List cues) { + listeners.sendEvent(C.INDEX_UNSET, listener -> listener.onCues(cues)); + } + + // MetadataOutput callbacks + + @Override + public void onMetadata(Metadata metadata) { + listeners.sendEvent(C.INDEX_UNSET, listener -> listener.onMetadata(metadata)); + } + + // DeviceListener callbacks + + @Override + public void onDeviceInfoChanged(DeviceInfo deviceInfo) { + listeners.sendEvent(C.INDEX_UNSET, listener -> listener.onDeviceInfoChanged(deviceInfo)); + } + + @Override + public void onDeviceVolumeChanged(int volume, boolean muted) { + listeners.sendEvent(C.INDEX_UNSET, listener -> listener.onDeviceVolumeChanged(volume, muted)); + } + + // EventListener callbacks + + @Override + public void onTimelineChanged(Timeline timeline, @TimelineChangeReason int reason) { + listeners.sendEvent( + EVENT_TIMELINE_CHANGED, listener -> listener.onTimelineChanged(timeline, reason)); + } + + @Override + @SuppressWarnings("deprecation") // Forwarding to deprecated method. + public void onTimelineChanged( + Timeline timeline, @Nullable Object manifest, @TimelineChangeReason int reason) { + listeners.sendEvent( + EVENT_TIMELINE_CHANGED, + listener -> listener.onTimelineChanged(timeline, manifest, reason)); + } + + @Override + public void onMediaItemTransition( + @Nullable MediaItem mediaItem, @MediaItemTransitionReason int reason) { + listeners.sendEvent( + EVENT_MEDIA_ITEM_TRANSITION, + listener -> listener.onMediaItemTransition(mediaItem, reason)); + } + + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + listeners.sendEvent( + EVENT_TRACKS_CHANGED, listener -> listener.onTracksChanged(trackGroups, trackSelections)); + } + + @Override + public void onStaticMetadataChanged(List metadataList) { + listeners.sendEvent( + EVENT_STATIC_METADATA_CHANGED, + listener -> listener.onStaticMetadataChanged(metadataList)); + } + + @Override + public void onMediaMetadataChanged(MediaMetadata mediaMetadata) { + listeners.sendEvent( + EVENT_MEDIA_METADATA_CHANGED, listener -> listener.onMediaMetadataChanged(mediaMetadata)); + } + + @Override + public void onIsLoadingChanged(boolean isLoading) { + listeners.sendEvent( + EVENT_IS_LOADING_CHANGED, listener -> listener.onIsLoadingChanged(isLoading)); + } + + @Override + @SuppressWarnings("deprecation") // Forwarding to deprecated method. + public void onLoadingChanged(boolean isLoading) { + listeners.sendEvent( + EVENT_IS_LOADING_CHANGED, listener -> listener.onLoadingChanged(isLoading)); + } + + @Override + public void onAvailableCommandsChanged(Commands availableCommands) { + lastReceivedCommands = availableCommands; + maybeAdvertiseAvailableCommands(); + } + + @Override + @SuppressWarnings("deprecation") // Forwarding to deprecated method. + public void onPlayerStateChanged(boolean playWhenReady, @State int playbackState) { + listeners.sendEvent( + C.INDEX_UNSET, listener -> listener.onPlayerStateChanged(playWhenReady, playbackState)); + } + + @Override + public void onPlaybackStateChanged(@State int state) { + listeners.sendEvent( + EVENT_PLAYBACK_STATE_CHANGED, listener -> listener.onPlaybackStateChanged(state)); + } + + @Override + public void onPlayWhenReadyChanged(boolean playWhenReady, @State int reason) { + listeners.sendEvent( + EVENT_PLAY_WHEN_READY_CHANGED, + listener -> listener.onPlayWhenReadyChanged(playWhenReady, reason)); + } + + @Override + public void onPlaybackSuppressionReasonChanged( + @PlaybackSuppressionReason int playbackSuppressionReason) { + listeners.sendEvent( + EVENT_PLAYBACK_SUPPRESSION_REASON_CHANGED, + listener -> listener.onPlaybackSuppressionReasonChanged(playbackSuppressionReason)); + } + + @Override + public void onIsPlayingChanged(boolean isPlaying) { + listeners.sendEvent( + EVENT_IS_PLAYING_CHANGED, listener -> listener.onIsPlayingChanged(isPlaying)); + } + + @Override + public void onRepeatModeChanged(@RepeatMode int repeatMode) { + listeners.sendEvent( + EVENT_REPEAT_MODE_CHANGED, listener -> listener.onRepeatModeChanged(repeatMode)); + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + listeners.sendEvent( + EVENT_SHUFFLE_MODE_ENABLED_CHANGED, + listener -> listener.onShuffleModeEnabledChanged(shuffleModeEnabled)); + } + + @Override + public void onPlayerError(ExoPlaybackException error) { + listeners.sendEvent(EVENT_PLAYER_ERROR, listener -> listener.onPlayerError(error)); + } + + @Override + @SuppressWarnings("deprecation") // Forwarding to deprecated method. + public void onPositionDiscontinuity(@DiscontinuityReason int reason) { + listeners.sendEvent( + EVENT_POSITION_DISCONTINUITY, listener -> listener.onPositionDiscontinuity(reason)); + } + + @Override + public void onPositionDiscontinuity( + PositionInfo oldPosition, PositionInfo newPosition, @DiscontinuityReason int reason) { + listeners.sendEvent( + EVENT_POSITION_DISCONTINUITY, + listener -> listener.onPositionDiscontinuity(oldPosition, newPosition, reason)); + } + + @Override + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + listeners.sendEvent( + EVENT_PLAYBACK_PARAMETERS_CHANGED, + listener -> listener.onPlaybackParametersChanged(playbackParameters)); + } + + @Override + @SuppressWarnings("deprecation") // Forwarding to deprecated method. + public void onSeekProcessed() { + listeners.sendEvent(C.INDEX_UNSET, EventListener::onSeekProcessed); + } + + @Override + public void onEvents(Player player, Events events) { + // Do nothing, individual callbacks will trigger this event on behalf of the forwarding + // player. + } + + public void maybeAdvertiseAvailableCommands() { + Commands commandsToAdvertise = filterCommands(lastReceivedCommands, player.disabledCommands); + if (!commandsToAdvertise.equals(lastAdvertisedCommands)) { + lastAdvertisedCommands = commandsToAdvertise; + listeners.sendEvent( + EVENT_AVAILABLE_COMMANDS_CHANGED, + listener -> listener.onAvailableCommandsChanged(commandsToAdvertise)); + } + } + } + + /** + * Wraps an {@link EventListener} as a {@link Listener} so that it can be used by the {@link + * ForwardingListener}. + */ + private static class EventListenerWrapper implements Listener { + private final EventListener listener; + + /** Wraps an {@link EventListener}. */ + public EventListenerWrapper(EventListener listener) { + this.listener = listener; + } + + // EventListener callbacks + + @Override + @SuppressWarnings("deprecation") // Forwarding to deprecated method. + public void onTimelineChanged( + Timeline timeline, @Nullable Object manifest, @TimelineChangeReason int reason) { + listener.onTimelineChanged(timeline, manifest, reason); + } + + @Override + public void onTimelineChanged(Timeline timeline, @TimelineChangeReason int reason) { + listener.onTimelineChanged(timeline, reason); + } + + @Override + public void onMediaItemTransition( + @Nullable MediaItem mediaItem, @MediaItemTransitionReason int reason) { + listener.onMediaItemTransition(mediaItem, reason); + } + + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + listener.onTracksChanged(trackGroups, trackSelections); + } + + @Override + public void onStaticMetadataChanged(List metadataList) { + listener.onStaticMetadataChanged(metadataList); + } + + @Override + public void onMediaMetadataChanged(MediaMetadata mediaMetadata) { + listener.onMediaMetadataChanged(mediaMetadata); + } + + @Override + public void onIsLoadingChanged(boolean isLoading) { + listener.onIsLoadingChanged(isLoading); + } + + @Override + @SuppressWarnings("deprecation") // Forwarding to deprecated method. + public void onLoadingChanged(boolean isLoading) { + listener.onLoadingChanged(isLoading); + } + + @Override + public void onAvailableCommandsChanged(Commands availableCommands) { + listener.onAvailableCommandsChanged(availableCommands); + } + + @Override + @SuppressWarnings("deprecation") // Forwarding to deprecated method. + public void onPlayerStateChanged(boolean playWhenReady, @State int playbackState) { + listener.onPlayerStateChanged(playWhenReady, playbackState); + } + + @Override + public void onPlaybackStateChanged(@State int state) { + listener.onPlaybackStateChanged(state); + } + + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @PlayWhenReadyChangeReason int reason) { + listener.onPlayWhenReadyChanged(playWhenReady, reason); + } + + @Override + public void onPlaybackSuppressionReasonChanged( + @PlaybackSuppressionReason int playbackSuppressionReason) { + listener.onPlaybackSuppressionReasonChanged(playbackSuppressionReason); + } + + @Override + public void onIsPlayingChanged(boolean isPlaying) { + listener.onIsPlayingChanged(isPlaying); + } + + @Override + public void onRepeatModeChanged(@RepeatMode int repeatMode) { + listener.onRepeatModeChanged(repeatMode); + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + listener.onShuffleModeEnabledChanged(shuffleModeEnabled); + } + + @Override + public void onPlayerError(ExoPlaybackException error) { + listener.onPlayerError(error); + } + + @Override + @SuppressWarnings("deprecation") // Forwarding to deprecated method. + public void onPositionDiscontinuity(@DiscontinuityReason int reason) { + listener.onPositionDiscontinuity(reason); + } + + @Override + public void onPositionDiscontinuity( + PositionInfo oldPosition, PositionInfo newPosition, @DiscontinuityReason int reason) { + listener.onPositionDiscontinuity(oldPosition, newPosition, reason); + } + + @Override + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + listener.onPlaybackParametersChanged(playbackParameters); + } + + @Override + @SuppressWarnings("deprecation") // Forwarding to deprecated method. + public void onSeekProcessed() { + listener.onSeekProcessed(); + } + + @Override + public void onEvents(Player player, Events events) { + listener.onEvents(player, events); + } + + // Other Listener callbacks, they should never be invoked on this wrapper. + + @Override + public void onMetadata(Metadata metadata) { + throw new IllegalStateException(); + } + + @Override + public void onCues(List cues) { + throw new IllegalStateException(); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof EventListenerWrapper)) { + return false; + } + + EventListenerWrapper that = (EventListenerWrapper) o; + return listener.equals(that.listener); + } + + @Override + public int hashCode() { + return listener.hashCode(); + } + } + + /** Returns the remaining available commands after removing disabled commands. */ + private static Commands filterCommands(Commands availableCommands, Commands disabledCommands) { + if (disabledCommands.size() == 0) { + return availableCommands; + } + + Commands.Builder builder = new Commands.Builder(); + for (int i = 0; i < availableCommands.size(); i++) { + int command = availableCommands.get(i); + builder.addIf(command, !disabledCommands.contains(command)); + } + return builder.build(); + } } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/ListenerSet.java b/library/common/src/main/java/com/google/android/exoplayer2/util/ListenerSet.java index fe220b1946..a4436ccdab 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/ListenerSet.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/ListenerSet.java @@ -155,6 +155,11 @@ public final class ListenerSet { } } + /** Returns the number of added listeners. */ + public int size() { + return listeners.size(); + } + /** * Adds an event that is sent to the listeners when {@link #flushEvents} is called. * diff --git a/library/common/src/test/java/com/google/android/exoplayer2/ForwardingPlayerTest.java b/library/common/src/test/java/com/google/android/exoplayer2/ForwardingPlayerTest.java new file mode 100644 index 0000000000..57ae90b29c --- /dev/null +++ b/library/common/src/test/java/com/google/android/exoplayer2/ForwardingPlayerTest.java @@ -0,0 +1,386 @@ +/* + * Copyright (C) 2021 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 com.google.android.exoplayer2; + +import static com.google.android.exoplayer2.Player.COMMAND_PLAY_PAUSE; +import static com.google.android.exoplayer2.Player.COMMAND_PREPARE_STOP; +import static com.google.android.exoplayer2.Player.COMMAND_SEEK_TO_MEDIA_ITEM; +import static com.google.android.exoplayer2.Player.EVENT_AVAILABLE_COMMANDS_CHANGED; +import static com.google.android.exoplayer2.Player.EVENT_PLAYBACK_STATE_CHANGED; +import static com.google.android.exoplayer2.Player.STATE_READY; +import static com.google.android.exoplayer2.util.Assertions.checkState; +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import android.os.Looper; +import androidx.annotation.Nullable; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.testutil.StubExoPlayer; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Queue; +import java.util.Set; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentMatcher; +import org.mockito.InOrder; +import org.robolectric.shadows.ShadowLooper; + +/** Unit test for {@link ForwardingPlayer}. */ +@RunWith(AndroidJUnit4.class) +public class ForwardingPlayerTest { + @Test + public void getAvailableCommands_withDisabledCommands_filtersDisabledCommands() { + Player player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP); + + ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player); + forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_PREPARE_STOP)); + + assertThat(forwardingPlayer.getAvailableCommands()) + .isEqualTo(buildCommands(COMMAND_PLAY_PAUSE)); + } + + @Test + public void getAvailableCommands_playerAvailableCommandsChanged_returnsFreshCommands() { + FakePlayer player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP); + ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player); + + forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_PREPARE_STOP)); + assertThat(forwardingPlayer.getAvailableCommands()) + .isEqualTo(buildCommands(COMMAND_PLAY_PAUSE)); + player.setAvailableCommands( + buildCommands(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP, COMMAND_SEEK_TO_MEDIA_ITEM)); + + assertThat(forwardingPlayer.getAvailableCommands()) + .isEqualTo(buildCommands(COMMAND_PLAY_PAUSE, COMMAND_SEEK_TO_MEDIA_ITEM)); + } + + @Test + public void isCommandAvailable_withDisabledCommands_filtersDisabledCommands() { + Player player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP); + + ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player); + forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_PREPARE_STOP)); + + assertThat(forwardingPlayer.isCommandAvailable(COMMAND_PLAY_PAUSE)).isTrue(); + assertThat(forwardingPlayer.isCommandAvailable(COMMAND_PREPARE_STOP)).isFalse(); + } + + @Test + public void setDisabledCommands_triggersOnCommandsAvailableChanged() { + Player player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP); + Player.Listener listener = mock(Player.Listener.class); + ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player); + forwardingPlayer.addListener(listener); + + forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_PREPARE_STOP)); + ShadowLooper.idleMainLooper(); + + InOrder inOrder = inOrder(listener); + inOrder.verify(listener).onAvailableCommandsChanged(buildCommands(COMMAND_PLAY_PAUSE)); + inOrder + .verify(listener) + .onEvents( + same(forwardingPlayer), argThat(new EventsMatcher(EVENT_AVAILABLE_COMMANDS_CHANGED))); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void setDisabledCommands_withoutChangingAvailableCommands_noCallbackTriggered() { + Player player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP); + Player.Listener listener = mock(Player.Listener.class); + ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player); + forwardingPlayer.addListener(listener); + + forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_SEEK_TO_MEDIA_ITEM)); + ShadowLooper.idleMainLooper(); + + verifyNoMoreInteractions(listener); + } + + @Test + public void setDisabledCommands_multipleTimes_availableCommandsUpdated() { + Player player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP); + ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player); + + forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_SEEK_TO_MEDIA_ITEM)); + assertThat(forwardingPlayer.getAvailableCommands()) + .isEqualTo(buildCommands(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP)); + + forwardingPlayer.setDisabledCommands( + buildCommands(COMMAND_PREPARE_STOP, COMMAND_SEEK_TO_MEDIA_ITEM)); + assertThat(forwardingPlayer.getAvailableCommands()) + .isEqualTo(buildCommands(COMMAND_PLAY_PAUSE)); + } + + @Test + public void onCommandsAvailableChanged_listenerChangesCommandsRecursively_secondCallbackCalled() { + FakePlayer player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP); + ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player); + Player.Listener listener = + spy( + new Player.Listener() { + @Override + public void onAvailableCommandsChanged(Player.Commands availableCommands) { + // The callback changes the forwarding player's disabled commands triggering + // exactly one more callback. + forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_PREPARE_STOP)); + } + }); + forwardingPlayer.addListener(listener); + + Player.Commands updatedCommands = + buildCommands(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP, COMMAND_SEEK_TO_MEDIA_ITEM); + player.setAvailableCommands(updatedCommands); + player.forwardingListener.onAvailableCommandsChanged(updatedCommands); + ShadowLooper.idleMainLooper(); + + InOrder inOrder = inOrder(listener); + inOrder + .verify(listener) + .onAvailableCommandsChanged( + buildCommands(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP, COMMAND_SEEK_TO_MEDIA_ITEM)); + inOrder + .verify(listener) + .onAvailableCommandsChanged(buildCommands(COMMAND_PLAY_PAUSE, COMMAND_SEEK_TO_MEDIA_ITEM)); + inOrder + .verify(listener) + .onEvents( + same(forwardingPlayer), argThat(new EventsMatcher(EVENT_AVAILABLE_COMMANDS_CHANGED))); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void + interceptingOnAvailableCommandsChanged_withDisabledCommands_filtersDisabledCommands() { + FakePlayer player = new FakePlayer(); + Player.Listener listener = mock(Player.Listener.class); + ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player); + forwardingPlayer.addListener(listener); + + forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_PREPARE_STOP)); + ShadowLooper.idleMainLooper(); + // Setting the disabled commands did not affect the available commands, hence no callback was + // triggered. + verifyNoMoreInteractions(listener); + + // The wrapped player advertises new available commands. + Player.Commands updatedCommands = buildCommands(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP); + player.setAvailableCommands(updatedCommands); + player.forwardingListener.onAvailableCommandsChanged(updatedCommands); + ShadowLooper.idleMainLooper(); + verify(listener).onAvailableCommandsChanged(buildCommands(COMMAND_PLAY_PAUSE)); + verify(listener) + .onEvents( + same(forwardingPlayer), argThat(new EventsMatcher(EVENT_AVAILABLE_COMMANDS_CHANGED))); + verifyNoMoreInteractions(listener); + } + + @Test + public void + interceptingOnAvailableCommandsChanged_withDisabledCommandsButAvailableCommandsNotChanged_doesNotForwardCallback() { + FakePlayer player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP); + Player.Listener listener = mock(Player.Listener.class); + ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player); + forwardingPlayer.addListener(listener); + + // Disable commands that do not affect the available commands. + forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_SEEK_TO_MEDIA_ITEM)); + ShadowLooper.idleMainLooper(); + verifyNoMoreInteractions(listener); + + // The wrapped player advertises new available commands which, after filtering the disabled + // commands, do not change the available commands. + Player.Commands updatedCommands = + buildCommands(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP, COMMAND_SEEK_TO_MEDIA_ITEM); + player.setAvailableCommands(updatedCommands); + player.forwardingListener.onAvailableCommandsChanged(updatedCommands); + ShadowLooper.idleMainLooper(); + + verifyNoMoreInteractions(listener); + } + + @Test + public void removeListener_removesListenerFromPlayer() { + FakePlayer player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP); + Player.Listener listener = mock(Player.Listener.class); + ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player); + + forwardingPlayer.addListener(listener); + assertThat(player.forwardingListener).isNotNull(); + forwardingPlayer.removeListener(listener); + assertThat(player.forwardingListener).isNull(); + } + + @Test + public void addEventListener_forwardsEventListenerEvents() { + FakePlayer player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP); + Player.EventListener eventListener = mock(Player.EventListener.class); + ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player); + + forwardingPlayer.addListener(eventListener); + player.forwardingListener.onPlaybackStateChanged(STATE_READY); + ShadowLooper.idleMainLooper(); + + InOrder inOrder = inOrder(eventListener); + inOrder.verify(eventListener).onPlaybackStateChanged(STATE_READY); + inOrder + .verify(eventListener) + .onEvents(same(forwardingPlayer), argThat(new EventsMatcher(EVENT_PLAYBACK_STATE_CHANGED))); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void forwardingListener_overridesAllListenerMethods() throws Exception { + // Check with reflection that ForwardingListener in ForwardingPlayer overrides all Listener + // methods. + Class forwardingListenerClass = getNestedClass("ForwardingListener"); + List publicListenerMethods = getPublicMethods(Player.Listener.class); + for (Method method : publicListenerMethods) { + assertThat( + forwardingListenerClass.getDeclaredMethod( + method.getName(), method.getParameterTypes())) + .isNotNull(); + } + } + + @Test + public void eventListenerWrapper_overridesAllEventListenerMethods() throws Exception { + // Check with reflection that EventListenerWrapper in ForwardingPlayer overrides all + // EventListener methods. + Class listenerWrapperClass = getNestedClass("EventListenerWrapper"); + List publicListenerMethods = getPublicMethods(Player.EventListener.class); + for (Method method : publicListenerMethods) { + assertThat( + listenerWrapperClass.getDeclaredMethod(method.getName(), method.getParameterTypes())) + .isNotNull(); + } + } + + private static class FakePlayer extends StubExoPlayer { + private Commands availableCommands; + /** + * Supports up to 1 registered listener, named deliberately forwardingListener to emphasize its + * purpose. + */ + @Nullable private Listener forwardingListener; + + public FakePlayer() { + this.availableCommands = Commands.EMPTY; + } + + public FakePlayer(@Command int... commands) { + this.availableCommands = new Commands.Builder().addAll(commands).build(); + } + + @Override + public void addListener(Listener listener) { + checkState(this.forwardingListener == null); + this.forwardingListener = listener; + } + + @Override + public void removeListener(Listener listener) { + checkState(this.forwardingListener.equals(listener)); + this.forwardingListener = null; + } + + @Override + public Commands getAvailableCommands() { + return availableCommands; + } + + @Override + public Looper getApplicationLooper() { + return Looper.getMainLooper(); + } + + public void setAvailableCommands(Commands availableCommands) { + this.availableCommands = availableCommands; + } + } + + private static Player.Commands buildCommands(@Player.Command int... commands) { + return new Player.Commands.Builder().addAll(commands).build(); + } + + private Class getNestedClass(String className) { + for (Class declaredClass : ForwardingPlayer.class.getDeclaredClasses()) { + if (declaredClass.getSimpleName().equals(className)) { + return declaredClass; + } + } + throw new IllegalStateException(); + } + + private static class EventsMatcher implements ArgumentMatcher { + private final int[] events; + + private EventsMatcher(int... events) { + this.events = events; + } + + @Override + public boolean matches(Player.Events argument) { + if (events.length != argument.size()) { + return false; + } + for (int event : events) { + if (!argument.contains(event)) { + return false; + } + } + return true; + } + } + + /** Returns all the methods of Java interface. */ + private static List getPublicMethods(Class anInterface) { + assertThat(anInterface.isInterface()).isTrue(); + // Run a BFS over all extended interfaces to inspect them all. + Queue> interfacesQueue = new ArrayDeque<>(); + interfacesQueue.add(anInterface); + Set> interfaces = new HashSet<>(); + while (!interfacesQueue.isEmpty()) { + Class currentInterface = interfacesQueue.remove(); + if (interfaces.add(currentInterface)) { + Collections.addAll(interfacesQueue, currentInterface.getInterfaces()); + } + } + + List list = new ArrayList<>(); + for (Class currentInterface : interfaces) { + for (Method method : currentInterface.getDeclaredMethods()) { + if (Modifier.isPublic(method.getModifiers())) { + list.add(method); + } + } + } + + return list; + } +}