From 1fb128df36f9c2a8ecbac5de319bd4cfa5b56f6a Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 29 Apr 2019 10:31:36 +0100 Subject: [PATCH] Move playback session manager to core library. This allows to use the session management capabilities for other analytics purposes. PiperOrigin-RevId: 245710588 --- .../DefaultPlaybackSessionManager.java | 359 +++++++ .../analytics/PlaybackSessionManager.java | 120 +++ .../DefaultPlaybackSessionManagerTest.java | 957 ++++++++++++++++++ 3 files changed, 1436 insertions(+) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java new file mode 100644 index 0000000000..b336d84dd2 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java @@ -0,0 +1,359 @@ +/* + * Copyright (C) 2019 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.analytics; + +import androidx.annotation.Nullable; +import android.util.Base64; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Player.DiscontinuityReason; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.analytics.AnalyticsListener.EventTime; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Random; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * Default {@link PlaybackSessionManager} which instantiates a new session for each window in the + * timeline and also for each ad within the windows. + * + *

Sessions are identified by Base64-encoded, URL-safe, random strings. + */ +public final class DefaultPlaybackSessionManager implements PlaybackSessionManager { + + private static final Random RANDOM = new Random(); + private static final int SESSION_ID_LENGTH = 12; + + private final Timeline.Window window; + private final Timeline.Period period; + private final HashMap sessions; + + @MonotonicNonNull private Listener listener; + private Timeline currentTimeline; + @Nullable private MediaPeriodId currentMediaPeriodId; + @Nullable private String activeSessionId; + + /** Creates session manager. */ + public DefaultPlaybackSessionManager() { + window = new Timeline.Window(); + period = new Timeline.Period(); + sessions = new HashMap<>(); + currentTimeline = Timeline.EMPTY; + } + + @Override + public void setListener(Listener listener) { + this.listener = listener; + } + + @Override + public synchronized String getSessionForMediaPeriodId( + Timeline timeline, MediaPeriodId mediaPeriodId) { + int windowIndex = timeline.getPeriodByUid(mediaPeriodId.periodUid, period).windowIndex; + return getOrAddSession(windowIndex, mediaPeriodId).sessionId; + } + + @Override + public synchronized boolean belongsToSession(EventTime eventTime, String sessionId) { + SessionDescriptor sessionDescriptor = sessions.get(sessionId); + if (sessionDescriptor == null) { + return false; + } + sessionDescriptor.maybeSetWindowSequenceNumber(eventTime.windowIndex, eventTime.mediaPeriodId); + return sessionDescriptor.belongsToSession(eventTime.windowIndex, eventTime.mediaPeriodId); + } + + @Override + public synchronized void updateSessions(EventTime eventTime) { + boolean isObviouslyFinished = + eventTime.mediaPeriodId != null + && currentMediaPeriodId != null + && eventTime.mediaPeriodId.windowSequenceNumber + < currentMediaPeriodId.windowSequenceNumber; + if (!isObviouslyFinished) { + SessionDescriptor descriptor = + getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); + if (!descriptor.isCreated) { + descriptor.isCreated = true; + Assertions.checkNotNull(listener).onSessionCreated(eventTime, descriptor.sessionId); + if (activeSessionId == null) { + updateActiveSession(eventTime, descriptor); + } + } + } + } + + @Override + public synchronized void handleTimelineUpdate(EventTime eventTime) { + Assertions.checkNotNull(listener); + Timeline previousTimeline = currentTimeline; + currentTimeline = eventTime.timeline; + Iterator iterator = sessions.values().iterator(); + while (iterator.hasNext()) { + SessionDescriptor session = iterator.next(); + if (!session.tryResolvingToNewTimeline(previousTimeline, currentTimeline)) { + iterator.remove(); + if (session.isCreated) { + if (session.sessionId.equals(activeSessionId)) { + activeSessionId = null; + } + listener.onSessionFinished( + eventTime, session.sessionId, /* automaticTransitionToNextPlayback= */ false); + } + } + } + handlePositionDiscontinuity(eventTime, Player.DISCONTINUITY_REASON_INTERNAL); + } + + @Override + public synchronized void handlePositionDiscontinuity( + EventTime eventTime, @DiscontinuityReason int reason) { + Assertions.checkNotNull(listener); + boolean hasAutomaticTransition = + reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION + || reason == Player.DISCONTINUITY_REASON_AD_INSERTION; + Iterator iterator = sessions.values().iterator(); + while (iterator.hasNext()) { + SessionDescriptor session = iterator.next(); + if (session.isFinishedAtEventTime(eventTime)) { + iterator.remove(); + if (session.isCreated) { + boolean isRemovingActiveSession = session.sessionId.equals(activeSessionId); + boolean isAutomaticTransition = hasAutomaticTransition && isRemovingActiveSession; + if (isRemovingActiveSession) { + activeSessionId = null; + } + listener.onSessionFinished(eventTime, session.sessionId, isAutomaticTransition); + } + } + } + SessionDescriptor activeSessionDescriptor = + getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); + if (eventTime.mediaPeriodId != null + && eventTime.mediaPeriodId.isAd() + && (currentMediaPeriodId == null + || currentMediaPeriodId.windowSequenceNumber + != eventTime.mediaPeriodId.windowSequenceNumber + || currentMediaPeriodId.adGroupIndex != eventTime.mediaPeriodId.adGroupIndex + || currentMediaPeriodId.adIndexInAdGroup != eventTime.mediaPeriodId.adIndexInAdGroup)) { + // New ad playback started. Find corresponding content session and notify ad playback started. + MediaPeriodId contentMediaPeriodId = + new MediaPeriodId( + eventTime.mediaPeriodId.periodUid, eventTime.mediaPeriodId.windowSequenceNumber); + SessionDescriptor contentSession = + getOrAddSession(eventTime.windowIndex, contentMediaPeriodId); + if (contentSession.isCreated && activeSessionDescriptor.isCreated) { + listener.onAdPlaybackStarted( + eventTime, contentSession.sessionId, activeSessionDescriptor.sessionId); + } + } + updateActiveSession(eventTime, activeSessionDescriptor); + } + + private SessionDescriptor getOrAddSession( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + // There should only be one matching session if mediaPeriodId is non-null. If mediaPeriodId is + // null, there may be multiple matching sessions with different window sequence numbers or + // adMediaPeriodIds. The best match is the one with the smaller window sequence number, and for + // windows with ads, the content session is preferred over ad sessions. + SessionDescriptor bestMatch = null; + long bestMatchWindowSequenceNumber = Long.MAX_VALUE; + for (SessionDescriptor sessionDescriptor : sessions.values()) { + sessionDescriptor.maybeSetWindowSequenceNumber(windowIndex, mediaPeriodId); + if (sessionDescriptor.belongsToSession(windowIndex, mediaPeriodId)) { + long windowSequenceNumber = sessionDescriptor.windowSequenceNumber; + if (windowSequenceNumber == C.INDEX_UNSET + || windowSequenceNumber < bestMatchWindowSequenceNumber) { + bestMatch = sessionDescriptor; + bestMatchWindowSequenceNumber = windowSequenceNumber; + } else if (windowSequenceNumber == bestMatchWindowSequenceNumber + && Util.castNonNull(bestMatch).adMediaPeriodId != null + && sessionDescriptor.adMediaPeriodId != null) { + bestMatch = sessionDescriptor; + } + } + } + if (bestMatch == null) { + String sessionId = generateSessionId(); + bestMatch = new SessionDescriptor(sessionId, windowIndex, mediaPeriodId); + sessions.put(sessionId, bestMatch); + } + return bestMatch; + } + + @RequiresNonNull("listener") + private void updateActiveSession(EventTime eventTime, SessionDescriptor sessionDescriptor) { + currentMediaPeriodId = eventTime.mediaPeriodId; + if (sessionDescriptor.isCreated && !sessionDescriptor.isActive) { + sessionDescriptor.isActive = true; + activeSessionId = sessionDescriptor.sessionId; + listener.onSessionActive(eventTime, sessionDescriptor.sessionId); + } + } + + private static String generateSessionId() { + byte[] randomBytes = new byte[SESSION_ID_LENGTH]; + RANDOM.nextBytes(randomBytes); + return Base64.encodeToString(randomBytes, Base64.URL_SAFE | Base64.NO_WRAP); + } + + /** + * Descriptor for a session. + * + *

The session may be described in one of three ways: + * + *

+ */ + private final class SessionDescriptor { + + private final String sessionId; + + private int windowIndex; + private long windowSequenceNumber; + private @MonotonicNonNull MediaPeriodId adMediaPeriodId; + + private boolean isCreated; + private boolean isActive; + + public SessionDescriptor( + String sessionId, int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + this.sessionId = sessionId; + this.windowIndex = windowIndex; + this.windowSequenceNumber = + mediaPeriodId == null ? C.INDEX_UNSET : mediaPeriodId.windowSequenceNumber; + if (mediaPeriodId != null && mediaPeriodId.isAd()) { + this.adMediaPeriodId = mediaPeriodId; + } + } + + public boolean tryResolvingToNewTimeline(Timeline oldTimeline, Timeline newTimeline) { + windowIndex = resolveWindowIndexToNewTimeline(oldTimeline, newTimeline, windowIndex); + if (windowIndex == C.INDEX_UNSET) { + return false; + } + if (adMediaPeriodId != null) { + int newPeriodIndex = newTimeline.getIndexOfPeriod(adMediaPeriodId.periodUid); + if (newPeriodIndex == C.INDEX_UNSET) { + return false; + } + } + return true; + } + + public boolean belongsToSession( + int eventWindowIndex, @Nullable MediaPeriodId eventMediaPeriodId) { + if (eventMediaPeriodId == null) { + // Events without concrete media period id are for all sessions of the same window. + return eventWindowIndex == windowIndex; + } + if (adMediaPeriodId == null) { + // If this is a content session, only events for content with the same window sequence + // number belong to this session. + return !eventMediaPeriodId.isAd() + && eventMediaPeriodId.windowSequenceNumber == windowSequenceNumber; + } + // If this is an ad session, only events for this ad belong to the session. + return eventMediaPeriodId.windowSequenceNumber == adMediaPeriodId.windowSequenceNumber + && eventMediaPeriodId.adGroupIndex == adMediaPeriodId.adGroupIndex + && eventMediaPeriodId.adIndexInAdGroup == adMediaPeriodId.adIndexInAdGroup; + } + + public void maybeSetWindowSequenceNumber( + int eventWindowIndex, @Nullable MediaPeriodId eventMediaPeriodId) { + if (windowSequenceNumber == C.INDEX_UNSET + && eventWindowIndex == windowIndex + && eventMediaPeriodId != null + && !eventMediaPeriodId.isAd()) { + // Set window sequence number for this session as soon as we have one. + windowSequenceNumber = eventMediaPeriodId.windowSequenceNumber; + } + } + + public boolean isFinishedAtEventTime(EventTime eventTime) { + if (windowSequenceNumber == C.INDEX_UNSET) { + // Sessions with unspecified window sequence number are kept until we know more. + return false; + } + if (eventTime.mediaPeriodId == null) { + // For event times without media period id (e.g. after seek to new window), we only keep + // sessions of this window. + return windowIndex != eventTime.windowIndex; + } + if (eventTime.mediaPeriodId.windowSequenceNumber > windowSequenceNumber) { + // All past window sequence numbers are finished. + return true; + } + if (adMediaPeriodId == null) { + // Current or future content is not finished. + return false; + } + int eventPeriodIndex = eventTime.timeline.getIndexOfPeriod(eventTime.mediaPeriodId.periodUid); + int adPeriodIndex = eventTime.timeline.getIndexOfPeriod(adMediaPeriodId.periodUid); + if (eventTime.mediaPeriodId.windowSequenceNumber < adMediaPeriodId.windowSequenceNumber + || eventPeriodIndex < adPeriodIndex) { + // Ads in future windows or periods are not finished. + return false; + } + if (eventPeriodIndex > adPeriodIndex) { + // Ads in past periods are finished. + return true; + } + if (eventTime.mediaPeriodId.isAd()) { + int eventAdGroup = eventTime.mediaPeriodId.adGroupIndex; + int eventAdIndex = eventTime.mediaPeriodId.adIndexInAdGroup; + // Finished if event is for an ad after this one in the same period. + return eventAdGroup > adMediaPeriodId.adGroupIndex + || (eventAdGroup == adMediaPeriodId.adGroupIndex + && eventAdIndex > adMediaPeriodId.adIndexInAdGroup); + } else { + eventTime.timeline.getPeriod(adPeriodIndex, period); + long adGroupTimeMs = + adMediaPeriodId.adGroupIndex < period.getAdGroupCount() + ? C.usToMs(period.getAdGroupTimeUs(adMediaPeriodId.adGroupIndex)) + : 0; + // Finished if the event is for content after this ad. + return adGroupTimeMs <= eventTime.currentPlaybackPositionMs; + } + } + + private int resolveWindowIndexToNewTimeline( + Timeline oldTimeline, Timeline newTimeline, int windowIndex) { + if (windowIndex >= oldTimeline.getWindowCount()) { + return windowIndex < newTimeline.getWindowCount() ? windowIndex : C.INDEX_UNSET; + } + oldTimeline.getWindow(windowIndex, window); + for (int periodIndex = window.firstPeriodIndex; + periodIndex <= window.lastPeriodIndex; + periodIndex++) { + Object periodUid = oldTimeline.getUidOfPeriod(periodIndex); + int newPeriodIndex = newTimeline.getIndexOfPeriod(periodUid); + if (newPeriodIndex != C.INDEX_UNSET) { + return newTimeline.getPeriod(newPeriodIndex, period).windowIndex; + } + } + return C.INDEX_UNSET; + } + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java new file mode 100644 index 0000000000..53d63e23fc --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2019 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.analytics; + +import com.google.android.exoplayer2.Player.DiscontinuityReason; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.analytics.AnalyticsListener.EventTime; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; + +/** + * Manager for active playback sessions. + * + *

The manager keeps track of the association between window index and/or media period id to + * session identifier. + */ +public interface PlaybackSessionManager { + + /** A listener for session updates. */ + interface Listener { + + /** + * Called when a new session is created as a result of {@link #updateSessions(EventTime)}. + * + * @param eventTime The {@link EventTime} at which the session is created. + * @param sessionId The identifier of the new session. + */ + void onSessionCreated(EventTime eventTime, String sessionId); + + /** + * Called when a session becomes active, i.e. playing in the foreground. + * + * @param eventTime The {@link EventTime} at which the session becomes active. + * @param sessionId The identifier of the session. + */ + void onSessionActive(EventTime eventTime, String sessionId); + + /** + * Called when a session is interrupted by ad playback. + * + * @param eventTime The {@link EventTime} at which the ad playback starts. + * @param contentSessionId The session identifier of the content session. + * @param adSessionId The identifier of the ad session. + */ + void onAdPlaybackStarted(EventTime eventTime, String contentSessionId, String adSessionId); + + /** + * Called when a session is permanently finished. + * + * @param eventTime The {@link EventTime} at which the session finished. + * @param sessionId The identifier of the finished session. + * @param automaticTransitionToNextPlayback Whether the session finished because of an automatic + * transition to the next playback item. + */ + void onSessionFinished( + EventTime eventTime, String sessionId, boolean automaticTransitionToNextPlayback); + } + + /** + * Sets the listener to be notified of session updates. Must be called before the session manager + * is used. + * + * @param listener The {@link Listener} to be notified of session updates. + */ + void setListener(Listener listener); + + /** + * Returns the session identifier for the given media period id. + * + *

Note that this will reserve a new session identifier if it doesn't exist yet, but will not + * call any {@link Listener} callbacks. + * + * @param timeline The timeline, {@code mediaPeriodId} is part of. + * @param mediaPeriodId A {@link MediaPeriodId}. + */ + String getSessionForMediaPeriodId(Timeline timeline, MediaPeriodId mediaPeriodId); + + /** + * Returns whether an event time belong to a session. + * + * @param eventTime The {@link EventTime}. + * @param sessionId A session identifier. + * @return Whether the event belongs to the specified session. + */ + boolean belongsToSession(EventTime eventTime, String sessionId); + + /** + * Updates or creates sessions based on a player {@link EventTime}. + * + * @param eventTime The {@link EventTime}. + */ + void updateSessions(EventTime eventTime); + + /** + * Updates the session associations to a new timeline. + * + * @param eventTime The event time with the timeline change. + */ + void handleTimelineUpdate(EventTime eventTime); + + /** + * Handles a position discontinuity. + * + * @param eventTime The event time of the position discontinuity. + * @param reason The {@link DiscontinuityReason}. + */ + void handlePositionDiscontinuity(EventTime eventTime, @DiscontinuityReason int reason); +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java new file mode 100644 index 0000000000..2993e960b4 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManagerTest.java @@ -0,0 +1,957 @@ +/* + * Copyright (C) 2019 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.analytics; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import androidx.annotation.Nullable; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.analytics.AnalyticsListener.EventTime; +import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.source.ads.AdPlaybackState; +import com.google.android.exoplayer2.testutil.FakeTimeline; +import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** Unit test for {@link DefaultPlaybackSessionManager}. */ +@RunWith(AndroidJUnit4.class) +public final class DefaultPlaybackSessionManagerTest { + + private DefaultPlaybackSessionManager sessionManager; + + @Mock private PlaybackSessionManager.Listener mockListener; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + sessionManager = new DefaultPlaybackSessionManager(); + sessionManager.setListener(mockListener); + } + + @Test + public void updateSessions_withoutMediaPeriodId_createsNewSession() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + EventTime eventTime = createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId */ null); + + sessionManager.updateSessions(eventTime); + + verify(mockListener).onSessionCreated(eq(eventTime), anyString()); + verify(mockListener).onSessionActive(eq(eventTime), anyString()); + verifyNoMoreInteractions(mockListener); + } + + @Test + public void updateSessions_withMediaPeriodId_createsNewSession() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaPeriodId mediaPeriodId = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); + EventTime eventTime = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId); + + sessionManager.updateSessions(eventTime); + + ArgumentCaptor sessionId = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime), sessionId.capture()); + verify(mockListener).onSessionActive(eventTime, sessionId.getValue()); + verifyNoMoreInteractions(mockListener); + assertThat(sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId)) + .isEqualTo(sessionId.getValue()); + } + + @Test + public void + updateSessions_ofSameWindow_withMediaPeriodId_afterWithoutMediaPeriodId_doesNotCreateNewSession() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaPeriodId mediaPeriodId = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); + EventTime eventTime1 = + createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + EventTime eventTime2 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId); + + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + + ArgumentCaptor sessionId = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime1), sessionId.capture()); + verify(mockListener).onSessionActive(eventTime1, sessionId.getValue()); + verifyNoMoreInteractions(mockListener); + assertThat(sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId)) + .isEqualTo(sessionId.getValue()); + } + + @Test + public void updateSessions_ofSameWindow_withAd_afterWithoutMediaPeriodId_createsNewSession() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaPeriodId mediaPeriodId = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0); + EventTime eventTime1 = + createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + EventTime eventTime2 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId); + + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + + ArgumentCaptor contentSessionId = ArgumentCaptor.forClass(String.class); + ArgumentCaptor adSessionId = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime1), contentSessionId.capture()); + verify(mockListener).onSessionCreated(eq(eventTime2), adSessionId.capture()); + verify(mockListener).onSessionActive(eventTime1, contentSessionId.getValue()); + verifyNoMoreInteractions(mockListener); + assertThat(contentSessionId).isNotEqualTo(adSessionId); + assertThat(sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId)) + .isEqualTo(adSessionId.getValue()); + } + + @Test + public void + updateSessions_ofSameWindow_withoutMediaPeriodId_afterMediaPeriodId_doesNotCreateNewSession() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaPeriodId mediaPeriodId = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); + EventTime eventTime1 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId); + EventTime eventTime2 = + createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + + ArgumentCaptor sessionId = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime1), sessionId.capture()); + verify(mockListener).onSessionActive(eventTime1, sessionId.getValue()); + verifyNoMoreInteractions(mockListener); + assertThat(sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId)) + .isEqualTo(sessionId.getValue()); + } + + @Test + public void updateSessions_ofSameWindow_withoutMediaPeriodId_afterAd_doesNotCreateNewSession() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaPeriodId mediaPeriodId = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0); + EventTime eventTime1 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId); + EventTime eventTime2 = + createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + + ArgumentCaptor sessionId = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime1), sessionId.capture()); + verify(mockListener).onSessionActive(eventTime1, sessionId.getValue()); + verifyNoMoreInteractions(mockListener); + assertThat(sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId)) + .isEqualTo(sessionId.getValue()); + } + + @Test + public void updateSessions_withOtherMediaPeriodId_ofSameWindow_doesNotCreateNewSession() { + Timeline timeline = + new FakeTimeline(new TimelineWindowDefinition(/* periodCount= */ 2, /* id= */ 0)); + MediaPeriodId mediaPeriodId1 = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); + MediaPeriodId mediaPeriodId2 = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 0); + EventTime eventTime1 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId1); + EventTime eventTime2 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId2); + + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + + ArgumentCaptor sessionId = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime1), sessionId.capture()); + verify(mockListener).onSessionActive(eventTime1, sessionId.getValue()); + verifyNoMoreInteractions(mockListener); + assertThat(sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId1)) + .isEqualTo(sessionId.getValue()); + assertThat(sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId2)) + .isEqualTo(sessionId.getValue()); + } + + @Test + public void updateSessions_withAd_ofSameWindow_createsNewSession() { + Timeline timeline = + new FakeTimeline(new TimelineWindowDefinition(/* periodCount= */ 2, /* id= */ 0)); + MediaPeriodId mediaPeriodId1 = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); + MediaPeriodId mediaPeriodId2 = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0); + EventTime eventTime1 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId1); + EventTime eventTime2 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId2); + + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + + ArgumentCaptor contentSessionId = ArgumentCaptor.forClass(String.class); + ArgumentCaptor adSessionId = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime1), contentSessionId.capture()); + verify(mockListener).onSessionActive(eventTime1, contentSessionId.getValue()); + verify(mockListener).onSessionCreated(eq(eventTime2), adSessionId.capture()); + verifyNoMoreInteractions(mockListener); + assertThat(contentSessionId).isNotEqualTo(adSessionId); + assertThat(sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId1)) + .isEqualTo(contentSessionId.getValue()); + assertThat(sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId2)) + .isEqualTo(adSessionId.getValue()); + } + + @Test + public void updateSessions_ofOtherWindow_createsNewSession() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 2); + EventTime eventTime1 = + createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + EventTime eventTime2 = + createEventTime(timeline, /* windowIndex= */ 1, /* mediaPeriodId= */ null); + + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + + ArgumentCaptor sessionId1 = ArgumentCaptor.forClass(String.class); + ArgumentCaptor sessionId2 = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime1), sessionId1.capture()); + verify(mockListener).onSessionCreated(eq(eventTime2), sessionId2.capture()); + verify(mockListener).onSessionActive(eventTime1, sessionId1.getValue()); + verifyNoMoreInteractions(mockListener); + assertThat(sessionId1).isNotEqualTo(sessionId2); + } + + @Test + public void updateSessions_withMediaPeriodId_ofOtherWindow_createsNewSession() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 2); + MediaPeriodId mediaPeriodId1 = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); + MediaPeriodId mediaPeriodId2 = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 1); + EventTime eventTime1 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId1); + EventTime eventTime2 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId2); + + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + + ArgumentCaptor sessionId1 = ArgumentCaptor.forClass(String.class); + ArgumentCaptor sessionId2 = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime1), sessionId1.capture()); + verify(mockListener).onSessionCreated(eq(eventTime2), sessionId2.capture()); + verify(mockListener).onSessionActive(eventTime1, sessionId1.getValue()); + verifyNoMoreInteractions(mockListener); + assertThat(sessionId1).isNotEqualTo(sessionId2); + assertThat(sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId1)) + .isEqualTo(sessionId1.getValue()); + assertThat(sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId2)) + .isEqualTo(sessionId2.getValue()); + } + + @Test + public void updateSessions_ofSameWindow_withNewWindowSequenceNumber_createsNewSession() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaPeriodId mediaPeriodId1 = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); + MediaPeriodId mediaPeriodId2 = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 1); + EventTime eventTime1 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId1); + EventTime eventTime2 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId2); + + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + + ArgumentCaptor sessionId1 = ArgumentCaptor.forClass(String.class); + ArgumentCaptor sessionId2 = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime1), sessionId1.capture()); + verify(mockListener).onSessionActive(eventTime1, sessionId1.getValue()); + verify(mockListener).onSessionCreated(eq(eventTime2), sessionId2.capture()); + verifyNoMoreInteractions(mockListener); + assertThat(sessionId1).isNotEqualTo(sessionId2); + assertThat(sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId1)) + .isEqualTo(sessionId1.getValue()); + assertThat(sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId2)) + .isEqualTo(sessionId2.getValue()); + } + + @Test + public void + updateSessions_withoutMediaPeriodId_andPreviouslyCreatedSessions_doesNotCreateNewSession() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaPeriodId mediaPeriodId1 = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); + MediaPeriodId mediaPeriodId2 = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 1); + MediaPeriodId mediaPeriodIdWithAd = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0); + EventTime eventTime1 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId1); + EventTime eventTime2 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId2); + EventTime eventTime3 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodIdWithAd); + EventTime eventTime4 = + createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + sessionManager.updateSessions(eventTime3); + sessionManager.updateSessions(eventTime4); + + verify(mockListener).onSessionCreated(eq(eventTime1), anyString()); + verify(mockListener).onSessionActive(eq(eventTime1), anyString()); + verify(mockListener).onSessionCreated(eq(eventTime2), anyString()); + verify(mockListener).onSessionCreated(eq(eventTime3), anyString()); + verifyNoMoreInteractions(mockListener); + } + + @Test + public void getSessionForMediaPeriodId_returnsValue_butDoesNotCreateSession() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaPeriodId mediaPeriodId = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); + String session = sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId); + + assertThat(session).isNotEmpty(); + verifyNoMoreInteractions(mockListener); + } + + @Test + public void updateSessions_afterSessionForMediaPeriodId_withSameMediaPeriodId_returnsSameValue() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaPeriodId mediaPeriodId = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); + EventTime eventTime = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId); + + String expectedSessionId = sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId); + sessionManager.updateSessions(eventTime); + + ArgumentCaptor sessionId = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime), sessionId.capture()); + verify(mockListener).onSessionActive(eventTime, sessionId.getValue()); + verifyNoMoreInteractions(mockListener); + assertThat(sessionId.getValue()).isEqualTo(expectedSessionId); + } + + @Test + public void updateSessions_withoutMediaPeriodId_afterSessionForMediaPeriodId_returnsSameValue() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaPeriodId mediaPeriodId = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); + EventTime eventTime = + createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + + String expectedSessionId = sessionManager.getSessionForMediaPeriodId(timeline, mediaPeriodId); + sessionManager.updateSessions(eventTime); + + ArgumentCaptor sessionId = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime), sessionId.capture()); + verify(mockListener).onSessionActive(eventTime, sessionId.getValue()); + verifyNoMoreInteractions(mockListener); + assertThat(sessionId.getValue()).isEqualTo(expectedSessionId); + } + + @Test + public void belongsToSession_withSameWindowIndex_returnsTrue() { + EventTime eventTime = + createEventTime(Timeline.EMPTY, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + EventTime eventTimeWithTimeline = + createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + MediaPeriodId mediaPeriodId = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); + EventTime eventTimeWithMediaPeriodId = + createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId); + sessionManager.updateSessions(eventTime); + + ArgumentCaptor sessionId = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime), sessionId.capture()); + assertThat(sessionManager.belongsToSession(eventTime, sessionId.getValue())).isTrue(); + assertThat(sessionManager.belongsToSession(eventTimeWithTimeline, sessionId.getValue())) + .isTrue(); + assertThat(sessionManager.belongsToSession(eventTimeWithMediaPeriodId, sessionId.getValue())) + .isTrue(); + } + + @Test + public void belongsToSession_withOtherWindowIndex_returnsFalse() { + EventTime eventTime = + createEventTime(Timeline.EMPTY, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + EventTime eventTimeOtherWindow = + createEventTime(Timeline.EMPTY, /* windowIndex= */ 1, /* mediaPeriodId= */ null); + Timeline timeline = new FakeTimeline(/* windowCount= */ 2); + MediaPeriodId mediaPeriodId = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 1); + EventTime eventTimeWithOtherMediaPeriodId = + createEventTime(timeline, /* windowIndex= */ 1, mediaPeriodId); + sessionManager.updateSessions(eventTime); + + ArgumentCaptor sessionId = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime), sessionId.capture()); + assertThat(sessionManager.belongsToSession(eventTimeOtherWindow, sessionId.getValue())) + .isFalse(); + assertThat( + sessionManager.belongsToSession(eventTimeWithOtherMediaPeriodId, sessionId.getValue())) + .isFalse(); + } + + @Test + public void belongsToSession_withOtherWindowSequenceNumber_returnsFalse() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaPeriodId mediaPeriodId1 = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); + MediaPeriodId mediaPeriodId2 = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 1); + EventTime eventTime1 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId1); + EventTime eventTime2 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId2); + sessionManager.updateSessions(eventTime1); + + ArgumentCaptor sessionId = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime1), sessionId.capture()); + assertThat(sessionManager.belongsToSession(eventTime2, sessionId.getValue())).isFalse(); + } + + @Test + public void belongsToSession_withAd_returnsFalse() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaPeriodId mediaPeriodId1 = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0); + MediaPeriodId mediaPeriodId2 = + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 1); + EventTime eventTime1 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId1); + EventTime eventTime2 = createEventTime(timeline, /* windowIndex= */ 0, mediaPeriodId2); + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + + ArgumentCaptor sessionId1 = ArgumentCaptor.forClass(String.class); + ArgumentCaptor sessionId2 = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime1), sessionId1.capture()); + verify(mockListener).onSessionCreated(eq(eventTime2), sessionId2.capture()); + assertThat(sessionManager.belongsToSession(eventTime2, sessionId1.getValue())).isFalse(); + assertThat(sessionManager.belongsToSession(eventTime1, sessionId2.getValue())).isFalse(); + assertThat(sessionManager.belongsToSession(eventTime2, sessionId2.getValue())).isTrue(); + } + + @Test + public void initialTimelineUpdate_finishesAllSessionsOutsideTimeline() { + EventTime eventTime1 = + createEventTime(Timeline.EMPTY, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + EventTime eventTime2 = + createEventTime(Timeline.EMPTY, /* windowIndex= */ 1, /* mediaPeriodId= */ null); + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + EventTime newTimelineEventTime = + createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + + sessionManager.handleTimelineUpdate(newTimelineEventTime); + + ArgumentCaptor sessionId1 = ArgumentCaptor.forClass(String.class); + ArgumentCaptor sessionId2 = ArgumentCaptor.forClass(String.class); + verify(mockListener).onSessionCreated(eq(eventTime1), sessionId1.capture()); + verify(mockListener).onSessionCreated(eq(eventTime2), sessionId2.capture()); + verify(mockListener).onSessionActive(eventTime1, sessionId1.getValue()); + verify(mockListener) + .onSessionFinished( + newTimelineEventTime, + sessionId2.getValue(), + /* automaticTransitionToNextPlayback= */ false); + verifyNoMoreInteractions(mockListener); + } + + @Test + public void dynamicTimelineUpdate_resolvesWindowIndices() { + Timeline initialTimeline = + new FakeTimeline( + new TimelineWindowDefinition(/* periodCount= */ 2, /* id= */ 100), + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ 200), + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ 300)); + EventTime eventForInitialTimelineId100 = + createEventTime( + initialTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + initialTimeline.getUidOfPeriod(/* periodIndex= */ 1), + /* windowSequenceNumber= */ 0)); + EventTime eventForInitialTimelineId200 = + createEventTime( + initialTimeline, + /* windowIndex= */ 1, + new MediaPeriodId( + initialTimeline.getUidOfPeriod(/* periodIndex= */ 2), + /* windowSequenceNumber= */ 1)); + EventTime eventForInitialTimelineId300 = + createEventTime( + initialTimeline, + /* windowIndex= */ 2, + new MediaPeriodId( + initialTimeline.getUidOfPeriod(/* periodIndex= */ 3), + /* windowSequenceNumber= */ 2)); + sessionManager.handleTimelineUpdate(eventForInitialTimelineId100); + sessionManager.updateSessions(eventForInitialTimelineId100); + sessionManager.updateSessions(eventForInitialTimelineId200); + sessionManager.updateSessions(eventForInitialTimelineId300); + String sessionId100 = + sessionManager.getSessionForMediaPeriodId( + initialTimeline, eventForInitialTimelineId100.mediaPeriodId); + String sessionId200 = + sessionManager.getSessionForMediaPeriodId( + initialTimeline, eventForInitialTimelineId200.mediaPeriodId); + String sessionId300 = + sessionManager.getSessionForMediaPeriodId( + initialTimeline, eventForInitialTimelineId300.mediaPeriodId); + + Timeline timelineUpdate = + new FakeTimeline( + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ 300), + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ 100)); + EventTime eventForTimelineUpdateId100 = + createEventTime( + timelineUpdate, + /* windowIndex= */ 1, + new MediaPeriodId( + timelineUpdate.getUidOfPeriod(/* periodIndex= */ 1), + /* windowSequenceNumber= */ 0)); + EventTime eventForTimelineUpdateId300 = + createEventTime( + timelineUpdate, + /* windowIndex= */ 0, + new MediaPeriodId( + timelineUpdate.getUidOfPeriod(/* periodIndex= */ 0), + /* windowSequenceNumber= */ 2)); + + sessionManager.handleTimelineUpdate(eventForTimelineUpdateId100); + String updatedSessionId100 = + sessionManager.getSessionForMediaPeriodId( + timelineUpdate, eventForTimelineUpdateId100.mediaPeriodId); + String updatedSessionId300 = + sessionManager.getSessionForMediaPeriodId( + timelineUpdate, eventForTimelineUpdateId300.mediaPeriodId); + + verify(mockListener).onSessionCreated(eventForInitialTimelineId100, sessionId100); + verify(mockListener).onSessionActive(eventForInitialTimelineId100, sessionId100); + verify(mockListener).onSessionCreated(eventForInitialTimelineId200, sessionId200); + verify(mockListener).onSessionCreated(eventForInitialTimelineId300, sessionId300); + verify(mockListener) + .onSessionFinished( + eventForTimelineUpdateId100, + sessionId200, + /* automaticTransitionToNextPlayback= */ false); + verifyNoMoreInteractions(mockListener); + assertThat(updatedSessionId100).isEqualTo(sessionId100); + assertThat(updatedSessionId300).isEqualTo(sessionId300); + } + + @Test + public void positionDiscontinuity_withinWindow_doesNotFinishSession() { + Timeline timeline = + new FakeTimeline(new TimelineWindowDefinition(/* periodCount= */ 2, /* id= */ 100)); + EventTime eventTime1 = + createEventTime( + timeline, + /* windowIndex= */ 0, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0)); + EventTime eventTime2 = + createEventTime( + timeline, + /* windowIndex= */ 0, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 0)); + sessionManager.handleTimelineUpdate(eventTime1); + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + + sessionManager.handlePositionDiscontinuity( + eventTime2, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); + + verify(mockListener).onSessionCreated(eq(eventTime1), anyString()); + verify(mockListener).onSessionActive(eq(eventTime1), anyString()); + verifyNoMoreInteractions(mockListener); + } + + @Test + public void positionDiscontinuity_toNewWindow_withPeriodTransitionReason_finishesSession() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 2); + EventTime eventTime1 = + createEventTime( + timeline, + /* windowIndex= */ 0, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0)); + EventTime eventTime2 = + createEventTime( + timeline, + /* windowIndex= */ 1, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 1)); + sessionManager.handleTimelineUpdate(eventTime1); + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + String sessionId1 = + sessionManager.getSessionForMediaPeriodId(timeline, eventTime1.mediaPeriodId); + String sessionId2 = + sessionManager.getSessionForMediaPeriodId(timeline, eventTime2.mediaPeriodId); + + sessionManager.handlePositionDiscontinuity( + eventTime2, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); + + verify(mockListener).onSessionCreated(eventTime1, sessionId1); + verify(mockListener).onSessionActive(eventTime1, sessionId1); + verify(mockListener).onSessionCreated(eq(eventTime2), anyString()); + verify(mockListener) + .onSessionFinished(eventTime2, sessionId1, /* automaticTransitionToNextPlayback= */ true); + verify(mockListener).onSessionActive(eventTime2, sessionId2); + verifyNoMoreInteractions(mockListener); + } + + @Test + public void positionDiscontinuity_toNewWindow_withSeekTransitionReason_finishesSession() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 2); + EventTime eventTime1 = + createEventTime( + timeline, + /* windowIndex= */ 0, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0)); + EventTime eventTime2 = + createEventTime( + timeline, + /* windowIndex= */ 1, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 1)); + sessionManager.handleTimelineUpdate(eventTime1); + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + String sessionId1 = + sessionManager.getSessionForMediaPeriodId(timeline, eventTime1.mediaPeriodId); + String sessionId2 = + sessionManager.getSessionForMediaPeriodId(timeline, eventTime2.mediaPeriodId); + + sessionManager.handlePositionDiscontinuity(eventTime2, Player.DISCONTINUITY_REASON_SEEK); + + verify(mockListener).onSessionCreated(eventTime1, sessionId1); + verify(mockListener).onSessionActive(eventTime1, sessionId1); + verify(mockListener).onSessionCreated(eq(eventTime2), anyString()); + verify(mockListener) + .onSessionFinished(eventTime2, sessionId1, /* automaticTransitionToNextPlayback= */ false); + verify(mockListener).onSessionActive(eventTime2, sessionId2); + verifyNoMoreInteractions(mockListener); + } + + @Test + public void positionDiscontinuity_toSameWindow_withoutMediaPeriodId_doesNotFinishSession() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + EventTime eventTime1 = + createEventTime( + timeline, + /* windowIndex= */ 0, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0)); + EventTime eventTime2 = + createEventTime(timeline, /* windowIndex= */ 0, /* mediaPeriodId= */ null); + sessionManager.handleTimelineUpdate(eventTime1); + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + + sessionManager.handlePositionDiscontinuity(eventTime2, Player.DISCONTINUITY_REASON_SEEK); + + verify(mockListener, never()).onSessionFinished(any(), anyString(), anyBoolean()); + } + + @Test + public void positionDiscontinuity_toNewWindow_finishesOnlyPastSessions() { + Timeline timeline = new FakeTimeline(/* windowCount= */ 4); + EventTime eventTime1 = + createEventTime( + timeline, + /* windowIndex= */ 0, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0)); + EventTime eventTime2 = + createEventTime( + timeline, + /* windowIndex= */ 1, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 1), /* windowSequenceNumber= */ 1)); + EventTime eventTime3 = + createEventTime( + timeline, + /* windowIndex= */ 2, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 2), /* windowSequenceNumber= */ 2)); + EventTime eventTime4 = + createEventTime( + timeline, + /* windowIndex= */ 3, + new MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 3), /* windowSequenceNumber= */ 3)); + sessionManager.handleTimelineUpdate(eventTime1); + sessionManager.updateSessions(eventTime1); + sessionManager.updateSessions(eventTime2); + sessionManager.updateSessions(eventTime3); + sessionManager.updateSessions(eventTime4); + String sessionId1 = + sessionManager.getSessionForMediaPeriodId(timeline, eventTime1.mediaPeriodId); + String sessionId2 = + sessionManager.getSessionForMediaPeriodId(timeline, eventTime2.mediaPeriodId); + + sessionManager.handlePositionDiscontinuity(eventTime3, Player.DISCONTINUITY_REASON_SEEK); + + verify(mockListener).onSessionCreated(eventTime1, sessionId1); + verify(mockListener).onSessionActive(eventTime1, sessionId1); + verify(mockListener).onSessionCreated(eventTime2, sessionId2); + verify(mockListener).onSessionCreated(eq(eventTime3), anyString()); + verify(mockListener).onSessionCreated(eq(eventTime4), anyString()); + verify(mockListener) + .onSessionFinished(eventTime3, sessionId1, /* automaticTransitionToNextPlayback= */ false); + verify(mockListener) + .onSessionFinished(eventTime3, sessionId2, /* automaticTransitionToNextPlayback= */ false); + verify(mockListener).onSessionActive(eq(eventTime3), anyString()); + verifyNoMoreInteractions(mockListener); + } + + @Test + public void positionDiscontinuity_fromAdToContent_finishesAd() { + Timeline adTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs =*/ 10 * C.MICROS_PER_SECOND, + new AdPlaybackState(/* adGroupTimesUs= */ 0, 5 * C.MICROS_PER_SECOND) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1))); + EventTime adEventTime1 = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0)); + EventTime adEventTime2 = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0)); + EventTime contentEventTime = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* windowSequenceNumber= */ 0, + /* nextAdGroupIndex= */ 1)); + sessionManager.handleTimelineUpdate(adEventTime1); + sessionManager.updateSessions(adEventTime1); + sessionManager.updateSessions(adEventTime2); + String adSessionId1 = + sessionManager.getSessionForMediaPeriodId(adTimeline, adEventTime1.mediaPeriodId); + + sessionManager.handlePositionDiscontinuity( + contentEventTime, Player.DISCONTINUITY_REASON_AD_INSERTION); + + verify(mockListener).onSessionCreated(adEventTime1, adSessionId1); + verify(mockListener).onSessionActive(adEventTime1, adSessionId1); + verify(mockListener).onSessionCreated(eq(adEventTime2), anyString()); + verify(mockListener) + .onSessionFinished( + contentEventTime, adSessionId1, /* automaticTransitionToNextPlayback= */ true); + verifyNoMoreInteractions(mockListener); + } + + @Test + public void positionDiscontinuity_fromContentToAd_doesNotFinishSessions() { + Timeline adTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs =*/ 10 * C.MICROS_PER_SECOND, + new AdPlaybackState( + /* adGroupTimesUs= */ 2 * C.MICROS_PER_SECOND, 5 * C.MICROS_PER_SECOND) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1))); + EventTime adEventTime1 = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0)); + EventTime adEventTime2 = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0)); + EventTime contentEventTime = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* windowSequenceNumber= */ 0, + /* nextAdGroupIndex= */ 0)); + sessionManager.handleTimelineUpdate(contentEventTime); + sessionManager.updateSessions(contentEventTime); + sessionManager.updateSessions(adEventTime1); + sessionManager.updateSessions(adEventTime2); + + sessionManager.handlePositionDiscontinuity( + adEventTime1, Player.DISCONTINUITY_REASON_AD_INSERTION); + + verify(mockListener, never()).onSessionFinished(any(), anyString(), anyBoolean()); + } + + @Test + public void positionDiscontinuity_fromAdToAd_finishesPastAds_andNotifiesAdPlaybackStated() { + Timeline adTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs =*/ 10 * C.MICROS_PER_SECOND, + new AdPlaybackState(/* adGroupTimesUs= */ 0, 5 * C.MICROS_PER_SECOND) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1))); + EventTime adEventTime1 = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0)); + EventTime adEventTime2 = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 0, + /* windowSequenceNumber= */ 0)); + EventTime contentEventTime = + createEventTime( + adTimeline, + /* windowIndex= */ 0, + new MediaPeriodId( + adTimeline.getUidOfPeriod(/* periodIndex= */ 0), + /* windowSequenceNumber= */ 0, + /* nextAdGroupIndex= */ 1)); + sessionManager.handleTimelineUpdate(contentEventTime); + sessionManager.updateSessions(contentEventTime); + sessionManager.updateSessions(adEventTime1); + sessionManager.updateSessions(adEventTime2); + String contentSessionId = + sessionManager.getSessionForMediaPeriodId(adTimeline, contentEventTime.mediaPeriodId); + String adSessionId1 = + sessionManager.getSessionForMediaPeriodId(adTimeline, adEventTime1.mediaPeriodId); + String adSessionId2 = + sessionManager.getSessionForMediaPeriodId(adTimeline, adEventTime2.mediaPeriodId); + + sessionManager.handlePositionDiscontinuity( + adEventTime1, Player.DISCONTINUITY_REASON_AD_INSERTION); + sessionManager.handlePositionDiscontinuity(adEventTime2, Player.DISCONTINUITY_REASON_SEEK); + + verify(mockListener).onSessionCreated(eq(contentEventTime), anyString()); + verify(mockListener).onSessionActive(eq(contentEventTime), anyString()); + verify(mockListener).onSessionCreated(adEventTime1, adSessionId1); + verify(mockListener).onSessionCreated(adEventTime2, adSessionId2); + verify(mockListener).onAdPlaybackStarted(adEventTime1, contentSessionId, adSessionId1); + verify(mockListener).onSessionActive(adEventTime1, adSessionId1); + verify(mockListener) + .onSessionFinished( + adEventTime2, adSessionId1, /* automaticTransitionToNextPlayback= */ false); + verify(mockListener).onAdPlaybackStarted(adEventTime2, contentSessionId, adSessionId2); + verify(mockListener).onSessionActive(adEventTime2, adSessionId2); + verifyNoMoreInteractions(mockListener); + } + + private static EventTime createEventTime( + Timeline timeline, int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + return new EventTime( + /* realtimeMs = */ 0, + timeline, + windowIndex, + mediaPeriodId, + /* eventPlaybackPositionMs= */ 0, + /* currentPlaybackPositionMs= */ 0, + /* totalBufferedDurationMs= */ 0); + } +}