Skip to content

Commit 6c5c5f2

Browse files
toniheicopybara-github
authored andcommitted
Ensure masking PlayerInfos are not merged into real PlayerInfo updates
The masking process in MediaController sends pending actions and creates placeholder PlayerInfo objects. Once all pending actions are received and we got a final new PlayerInfo from the session, the masking stops and we should continue using the real PlayerInfo from session again. The current tracking wasn't quite accurate, leading to some cases where we accidentally merge a masking PlayerInfo with a real one, which may cause exceptions since they may not be compatible. To solve this, we can - Clearly start the masking process by setting pendingPlayerInfo to the currently known PlayerInfo (not just when receiving intermediate updates). This has the nice side effect that we no longer need the pendingBundlingExclusions as we start off with no exlusions already and never have to deal with a pending state that has values excluded. - When ending the masking process, clearly copy over the last real PlayerInfo (without any exclusions), so that no merging with any placeholder/masking PlayerInfo object happens. There is a remaining bug where no-op updated never send a real PlayerInfo, meaning the controller stays in its masking state forever. This causes some of the test flakiness, which we can temporarily work around by sleeping to ensure the order of events in the remote process is guaranteed. PiperOrigin-RevId: 776629604
1 parent 7af5d71 commit 6c5c5f2

File tree

5 files changed

+147
-84
lines changed

5 files changed

+147
-84
lines changed

RELEASENOTES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
* Muxers:
2626
* IMA extension:
2727
* Session:
28+
* Fix bug where some controller changes that are not handled by the
29+
session may cause `IllegalStateExceptions`.
2830
* UI:
2931
* Fix bug where `PlayerSurface` inside re-usable components like
3032
`LazyColumn` didn't work correctly

libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@
4646
import android.os.Process;
4747
import android.os.RemoteException;
4848
import android.os.SystemClock;
49-
import android.util.Pair;
5049
import android.view.Surface;
5150
import android.view.SurfaceHolder;
5251
import android.view.SurfaceView;
@@ -144,7 +143,6 @@
144143
private long currentPositionMs;
145144
private long lastSetPlayWhenReadyCalledTimeMs;
146145
@Nullable private PlayerInfo pendingPlayerInfo;
147-
@Nullable private BundlingExclusions pendingBundlingExclusions;
148146
private Bundle sessionExtras;
149147

150148
public MediaControllerImplBase(
@@ -386,6 +384,10 @@ private ListenableFuture<SessionResult> dispatchRemoteSessionTask(
386384
new SessionResult(SessionResult.RESULT_INFO_SKIPPED));
387385
int sequenceNumber = result.getSequenceNumber();
388386
if (addToPendingMaskingOperations) {
387+
if (pendingMaskingSequencedFutureNumbers.isEmpty()) {
388+
// First pending operation, start masking PlayerInfo.
389+
pendingPlayerInfo = playerInfo;
390+
}
389391
pendingMaskingSequencedFutureNumbers.add(sequenceNumber);
390392
}
391393
try {
@@ -2765,36 +2767,29 @@ void onPlayerInfoChanged(PlayerInfo newPlayerInfo, BundlingExclusions bundlingEx
27652767
if (!isConnected()) {
27662768
return;
27672769
}
2768-
if (pendingPlayerInfo != null && pendingBundlingExclusions != null) {
2769-
Pair<PlayerInfo, BundlingExclusions> mergedPlayerInfoUpdate =
2770+
if (pendingPlayerInfo != null) {
2771+
pendingPlayerInfo =
27702772
mergePlayerInfo(
2771-
pendingPlayerInfo,
2772-
pendingBundlingExclusions,
2773-
newPlayerInfo,
2774-
bundlingExclusions,
2775-
intersectedPlayerCommands);
2776-
newPlayerInfo = mergedPlayerInfoUpdate.first;
2777-
bundlingExclusions = mergedPlayerInfoUpdate.second;
2778-
}
2779-
pendingPlayerInfo = null;
2780-
pendingBundlingExclusions = null;
2781-
if (!pendingMaskingSequencedFutureNumbers.isEmpty()) {
2782-
// We are still waiting for all pending masking operations to be handled.
2783-
pendingPlayerInfo = newPlayerInfo;
2784-
pendingBundlingExclusions = bundlingExclusions;
2785-
return;
2773+
pendingPlayerInfo, newPlayerInfo, bundlingExclusions, intersectedPlayerCommands);
2774+
if (pendingMaskingSequencedFutureNumbers.isEmpty()) {
2775+
// Finish masking.
2776+
newPlayerInfo = pendingPlayerInfo;
2777+
bundlingExclusions = BundlingExclusions.NONE;
2778+
pendingPlayerInfo = null;
2779+
} else {
2780+
// We are still waiting for all pending masking operations to be handled.
2781+
return;
2782+
}
27862783
}
27872784
PlayerInfo oldPlayerInfo = playerInfo;
27882785
// Assigning class variable now so that all getters called from listeners see the updated value.
27892786
// But we need to use a local final variable to ensure listeners get consistent parameters.
27902787
playerInfo =
27912788
mergePlayerInfo(
2792-
oldPlayerInfo,
2793-
/* oldBundlingExclusions= */ BundlingExclusions.NONE,
2794-
newPlayerInfo,
2795-
/* newBundlingExclusions= */ bundlingExclusions,
2796-
intersectedPlayerCommands)
2797-
.first;
2789+
oldPlayerInfo,
2790+
newPlayerInfo,
2791+
/* newBundlingExclusions= */ bundlingExclusions,
2792+
intersectedPlayerCommands);
27982793
PlayerInfo finalPlayerInfo = playerInfo;
27992794

28002795
@Nullable

libraries/session/src/main/java/androidx/media3/session/MediaUtils.java

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
import android.os.Parcelable;
2424
import android.os.SystemClock;
2525
import android.text.TextUtils;
26-
import android.util.Pair;
2726
import androidx.annotation.Nullable;
2827
import androidx.media3.common.C;
2928
import androidx.media3.common.Player;
@@ -128,39 +127,28 @@ public static Commands intersect(@Nullable Commands commands1, @Nullable Command
128127
* previousPlayerInfo} and taking into account the passed available commands.
129128
*
130129
* @param oldPlayerInfo The old {@link PlayerInfo}.
131-
* @param oldBundlingExclusions The bundling exclusions in the old {@link PlayerInfo}.
132130
* @param newPlayerInfo The new {@link PlayerInfo}.
133131
* @param newBundlingExclusions The bundling exclusions in the new {@link PlayerInfo}.
134132
* @param availablePlayerCommands The available commands to take into account when merging.
135-
* @return A pair with the resulting {@link PlayerInfo} and {@link BundlingExclusions}.
133+
* @return The resulting merged {@link PlayerInfo}.
136134
*/
137-
public static Pair<PlayerInfo, BundlingExclusions> mergePlayerInfo(
135+
public static PlayerInfo mergePlayerInfo(
138136
PlayerInfo oldPlayerInfo,
139-
BundlingExclusions oldBundlingExclusions,
140137
PlayerInfo newPlayerInfo,
141138
BundlingExclusions newBundlingExclusions,
142139
Commands availablePlayerCommands) {
143140
PlayerInfo mergedPlayerInfo = newPlayerInfo;
144-
BundlingExclusions mergedBundlingExclusions = newBundlingExclusions;
145141
if (newBundlingExclusions.isTimelineExcluded
146-
&& availablePlayerCommands.contains(Player.COMMAND_GET_TIMELINE)
147-
&& !oldBundlingExclusions.isTimelineExcluded) {
142+
&& availablePlayerCommands.contains(Player.COMMAND_GET_TIMELINE)) {
148143
// Use the previous timeline if it is excluded in the most recent update.
149144
mergedPlayerInfo = mergedPlayerInfo.copyWithTimeline(oldPlayerInfo.timeline);
150-
mergedBundlingExclusions =
151-
new BundlingExclusions(
152-
/* isTimelineExcluded= */ false, mergedBundlingExclusions.areCurrentTracksExcluded);
153145
}
154146
if (newBundlingExclusions.areCurrentTracksExcluded
155-
&& availablePlayerCommands.contains(Player.COMMAND_GET_TRACKS)
156-
&& !oldBundlingExclusions.areCurrentTracksExcluded) {
147+
&& availablePlayerCommands.contains(Player.COMMAND_GET_TRACKS)) {
157148
// Use the previous tracks if it is excluded in the most recent update.
158149
mergedPlayerInfo = mergedPlayerInfo.copyWithCurrentTracks(oldPlayerInfo.currentTracks);
159-
mergedBundlingExclusions =
160-
new BundlingExclusions(
161-
mergedBundlingExclusions.isTimelineExcluded, /* areCurrentTracksExcluded= */ false);
162150
}
163-
return new Pair<>(mergedPlayerInfo, mergedBundlingExclusions);
151+
return mergedPlayerInfo;
164152
}
165153

166154
/** Generates an array of {@code n} indices. */

libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerStateMaskingTest.java

Lines changed: 111 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
import org.junit.After;
5959
import org.junit.Before;
6060
import org.junit.ClassRule;
61+
import org.junit.Ignore;
6162
import org.junit.Rule;
6263
import org.junit.Test;
6364
import org.junit.rules.RuleChain;
@@ -3189,8 +3190,6 @@ public void onEvents(Player player, Player.Events events) {
31893190
}
31903191
};
31913192
threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener));
3192-
3193-
// Step 1: Report a discontinuity from item 0 to item 1 in the session.
31943193
PositionInfo oldPositionInfo =
31953194
new PositionInfo(
31963195
/* windowUid= */ timeline.getWindow(/* windowIndex= */ 0, new Window()).uid,
@@ -3217,13 +3216,24 @@ public void onEvents(Player player, Player.Events events) {
32173216
/* contentPositionMs= */ 0,
32183217
/* adGroupIndex= */ C.INDEX_UNSET,
32193218
/* adIndexInAdGroup= */ C.INDEX_UNSET);
3220-
remoteSession.getMockPlayer().setCurrentMediaItemIndex(1);
3221-
remoteSession
3222-
.getMockPlayer()
3223-
.notifyPositionDiscontinuity(
3224-
oldPositionInfo, newPositionInfo, Player.DISCONTINUITY_REASON_AUTO_TRANSITION);
3225-
// Step 2: Before step 1 can be handled by the controller, remove item 1.
3226-
threadTestRule.getHandler().postAndSync(() -> controller.removeMediaItem(/* index= */ 1));
3219+
3220+
threadTestRule
3221+
.getHandler()
3222+
.postAndSync(
3223+
() -> {
3224+
// Step 1: Report a discontinuity from item 0 to item 1 in the session.
3225+
remoteSession.getMockPlayer().setCurrentMediaItemIndex(1);
3226+
remoteSession
3227+
.getMockPlayer()
3228+
.notifyPositionDiscontinuity(
3229+
oldPositionInfo,
3230+
newPositionInfo,
3231+
Player.DISCONTINUITY_REASON_AUTO_TRANSITION);
3232+
// Step 2: Before step 1 can be handled by the controller, remove item 1.
3233+
controller.removeMediaItem(/* index= */ 1);
3234+
});
3235+
Thread.sleep(100); // TODO: b/428144538 - Remove sleep once we are guaranteed to get an update
3236+
// Send player updates for item removal.
32273237
remoteSession.getMockPlayer().setCurrentMediaItemIndex(0);
32283238
remoteSession.getMockPlayer().setTimeline(MediaTestUtils.createTimeline(/* windowCount= */ 1));
32293239
remoteSession.getMockPlayer().notifyPlaybackStateChanged(Player.STATE_ENDED);
@@ -3235,6 +3245,98 @@ public void onEvents(Player player, Player.Events events) {
32353245
assertThat(reportedStateChangeToEndedAtSameTimeAsDiscontinuity.get()).isTrue();
32363246
}
32373247

3248+
@Ignore // TODO: b/428144538 - Fix the logic to make this test possible
3249+
@Test
3250+
public void timelineUpdatesDuringMasking_withNoPlayerInfoUpdateFromSession_areResolvedCorrectly()
3251+
throws Exception {
3252+
Timeline timeline = MediaTestUtils.createTimeline(/* windowCount= */ 5);
3253+
remoteSession.getMockPlayer().setTimeline(timeline);
3254+
remoteSession.getMockPlayer().setCurrentMediaItemIndex(4);
3255+
remoteSession
3256+
.getMockPlayer()
3257+
.notifyTimelineChanged(Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED);
3258+
MediaController controller = controllerTestRule.createController(remoteSession.getToken());
3259+
CountDownLatch timelineChangeReported = new CountDownLatch(2);
3260+
ArrayList<Timeline> reportedTimelineChanges = new ArrayList<>();
3261+
ArrayList<Integer> reportedCurrentIndex = new ArrayList<>();
3262+
Player.Listener listener =
3263+
new Player.Listener() {
3264+
@Override
3265+
public void onEvents(Player player, Player.Events events) {
3266+
if (events.contains(Player.EVENT_TIMELINE_CHANGED)) {
3267+
reportedTimelineChanges.add(player.getCurrentTimeline());
3268+
reportedCurrentIndex.add(player.getCurrentMediaItemIndex());
3269+
timelineChangeReported.countDown();
3270+
}
3271+
}
3272+
};
3273+
threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener));
3274+
3275+
// Change masked Timeline in controller by assigning new single item, but don't change Timeline
3276+
// of the remote session. This means the timeline change needs to be reversed once the update is
3277+
// handled even if the remote session doesn't send any further update.
3278+
threadTestRule
3279+
.getHandler()
3280+
.postAndSync(
3281+
() ->
3282+
controller.setMediaItem(
3283+
new MediaItem.Builder().setMediaId("placeholder_id").build()));
3284+
3285+
assertThat(timelineChangeReported.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
3286+
assertThat(reportedTimelineChanges.get(0).getWindowCount()).isEqualTo(1);
3287+
assertThat(reportedTimelineChanges.get(1).getWindowCount()).isEqualTo(5);
3288+
assertThat(reportedCurrentIndex.get(0)).isEqualTo(0);
3289+
assertThat(reportedCurrentIndex.get(1)).isEqualTo(4);
3290+
}
3291+
3292+
@Test
3293+
public void
3294+
timelineUpdatesDuringMasking_withPlayerInfoUpdateExcludingTimeline_areResolvedCorrectly()
3295+
throws Exception {
3296+
Timeline timeline = MediaTestUtils.createTimeline(/* windowCount= */ 5);
3297+
remoteSession.getMockPlayer().setPlaybackState(Player.STATE_READY);
3298+
remoteSession.getMockPlayer().setTimeline(timeline);
3299+
remoteSession.getMockPlayer().setCurrentMediaItemIndex(4);
3300+
remoteSession
3301+
.getMockPlayer()
3302+
.notifyTimelineChanged(Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED);
3303+
MediaController controller = controllerTestRule.createController(remoteSession.getToken());
3304+
CountDownLatch timelineChangeReported = new CountDownLatch(2);
3305+
ArrayList<Timeline> reportedTimelineChanges = new ArrayList<>();
3306+
ArrayList<Integer> reportedCurrentIndex = new ArrayList<>();
3307+
Player.Listener listener =
3308+
new Player.Listener() {
3309+
@Override
3310+
public void onEvents(Player player, Player.Events events) {
3311+
if (events.contains(Player.EVENT_TIMELINE_CHANGED)) {
3312+
reportedTimelineChanges.add(player.getCurrentTimeline());
3313+
reportedCurrentIndex.add(player.getCurrentMediaItemIndex());
3314+
timelineChangeReported.countDown();
3315+
}
3316+
}
3317+
};
3318+
threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener));
3319+
3320+
// Change masked Timeline in controller by assigning new single item, but don't change Timeline
3321+
// of the remote session. This means the timeline change needs to be reversed once the update is
3322+
// handled even if the remote session doesn't send any further Timeline updates.
3323+
threadTestRule
3324+
.getHandler()
3325+
.postAndSync(
3326+
() ->
3327+
controller.setMediaItem(
3328+
new MediaItem.Builder().setMediaId("placeholder_id").build()));
3329+
Thread.sleep(100); // TODO: b/428144538 - Remove sleep once we are guaranteed to get an update
3330+
// Update session with new PlayerInfo, without changing Timeline.
3331+
remoteSession.getMockPlayer().notifyPlaybackStateChanged(Player.STATE_BUFFERING);
3332+
3333+
assertThat(timelineChangeReported.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
3334+
assertThat(reportedTimelineChanges.get(0).getWindowCount()).isEqualTo(1);
3335+
assertThat(reportedTimelineChanges.get(1).getWindowCount()).isEqualTo(5);
3336+
assertThat(reportedCurrentIndex.get(0)).isEqualTo(0);
3337+
assertThat(reportedCurrentIndex.get(1)).isEqualTo(4);
3338+
}
3339+
32383340
@Test
32393341
public void seekTo_indexLargerThanPlaylist_isIgnored() throws Exception {
32403342
MediaController controller = controllerTestRule.createController(remoteSession.getToken());

0 commit comments

Comments
 (0)