1 /* 2 * Copyright (C) 2019 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.google.android.exoplayer2.analytics; 17 18 import android.util.Base64; 19 import androidx.annotation.Nullable; 20 import com.google.android.exoplayer2.C; 21 import com.google.android.exoplayer2.Player; 22 import com.google.android.exoplayer2.Player.DiscontinuityReason; 23 import com.google.android.exoplayer2.Timeline; 24 import com.google.android.exoplayer2.analytics.AnalyticsListener.EventTime; 25 import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; 26 import com.google.android.exoplayer2.util.Assertions; 27 import com.google.android.exoplayer2.util.Supplier; 28 import com.google.android.exoplayer2.util.Util; 29 import java.util.HashMap; 30 import java.util.Iterator; 31 import java.util.Random; 32 import org.checkerframework.checker.nullness.qual.MonotonicNonNull; 33 34 /** 35 * Default {@link PlaybackSessionManager} which instantiates a new session for each window in the 36 * timeline and also for each ad within the windows. 37 * 38 * <p>By default, sessions are identified by Base64-encoded, URL-safe, random strings. 39 */ 40 public final class DefaultPlaybackSessionManager implements PlaybackSessionManager { 41 42 /** Default generator for unique session ids that are random, Based64-encoded and URL-safe. */ 43 public static final Supplier<String> DEFAULT_SESSION_ID_GENERATOR = 44 DefaultPlaybackSessionManager::generateDefaultSessionId; 45 46 private static final Random RANDOM = new Random(); 47 private static final int SESSION_ID_LENGTH = 12; 48 49 private final Timeline.Window window; 50 private final Timeline.Period period; 51 private final HashMap<String, SessionDescriptor> sessions; 52 private final Supplier<String> sessionIdGenerator; 53 54 private @MonotonicNonNull Listener listener; 55 private Timeline currentTimeline; 56 @Nullable private String currentSessionId; 57 58 /** 59 * Creates session manager with a {@link #DEFAULT_SESSION_ID_GENERATOR} to generate session ids. 60 */ DefaultPlaybackSessionManager()61 public DefaultPlaybackSessionManager() { 62 this(DEFAULT_SESSION_ID_GENERATOR); 63 } 64 65 /** 66 * Creates session manager. 67 * 68 * @param sessionIdGenerator A generator for new session ids. All generated session ids must be 69 * unique. 70 */ DefaultPlaybackSessionManager(Supplier<String> sessionIdGenerator)71 public DefaultPlaybackSessionManager(Supplier<String> sessionIdGenerator) { 72 this.sessionIdGenerator = sessionIdGenerator; 73 window = new Timeline.Window(); 74 period = new Timeline.Period(); 75 sessions = new HashMap<>(); 76 currentTimeline = Timeline.EMPTY; 77 } 78 79 @Override setListener(Listener listener)80 public void setListener(Listener listener) { 81 this.listener = listener; 82 } 83 84 @Override getSessionForMediaPeriodId( Timeline timeline, MediaPeriodId mediaPeriodId)85 public synchronized String getSessionForMediaPeriodId( 86 Timeline timeline, MediaPeriodId mediaPeriodId) { 87 int windowIndex = timeline.getPeriodByUid(mediaPeriodId.periodUid, period).windowIndex; 88 return getOrAddSession(windowIndex, mediaPeriodId).sessionId; 89 } 90 91 @Override belongsToSession(EventTime eventTime, String sessionId)92 public synchronized boolean belongsToSession(EventTime eventTime, String sessionId) { 93 SessionDescriptor sessionDescriptor = sessions.get(sessionId); 94 if (sessionDescriptor == null) { 95 return false; 96 } 97 sessionDescriptor.maybeSetWindowSequenceNumber(eventTime.windowIndex, eventTime.mediaPeriodId); 98 return sessionDescriptor.belongsToSession(eventTime.windowIndex, eventTime.mediaPeriodId); 99 } 100 101 @Override updateSessions(EventTime eventTime)102 public synchronized void updateSessions(EventTime eventTime) { 103 Assertions.checkNotNull(listener); 104 @Nullable SessionDescriptor currentSession = sessions.get(currentSessionId); 105 if (eventTime.mediaPeriodId != null && currentSession != null) { 106 // If we receive an event associated with a media period, then it needs to be either part of 107 // the current window if it's the first created media period, or a window that will be played 108 // in the future. Otherwise, we know that it belongs to a session that was already finished 109 // and we can ignore the event. 110 boolean isAlreadyFinished = 111 currentSession.windowSequenceNumber == C.INDEX_UNSET 112 ? currentSession.windowIndex != eventTime.windowIndex 113 : eventTime.mediaPeriodId.windowSequenceNumber < currentSession.windowSequenceNumber; 114 if (isAlreadyFinished) { 115 return; 116 } 117 } 118 SessionDescriptor eventSession = 119 getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); 120 if (currentSessionId == null) { 121 currentSessionId = eventSession.sessionId; 122 } 123 if (!eventSession.isCreated) { 124 eventSession.isCreated = true; 125 listener.onSessionCreated(eventTime, eventSession.sessionId); 126 } 127 if (eventSession.sessionId.equals(currentSessionId) && !eventSession.isActive) { 128 eventSession.isActive = true; 129 listener.onSessionActive(eventTime, eventSession.sessionId); 130 } 131 } 132 133 @Override 134 public synchronized void handleTimelineUpdate(EventTime eventTime) { 135 Assertions.checkNotNull(listener); 136 Timeline previousTimeline = currentTimeline; 137 currentTimeline = eventTime.timeline; 138 Iterator<SessionDescriptor> iterator = sessions.values().iterator(); 139 while (iterator.hasNext()) { 140 SessionDescriptor session = iterator.next(); 141 if (!session.tryResolvingToNewTimeline(previousTimeline, currentTimeline)) { 142 iterator.remove(); 143 if (session.isCreated) { 144 if (session.sessionId.equals(currentSessionId)) { 145 currentSessionId = null; 146 } 147 listener.onSessionFinished( 148 eventTime, session.sessionId, /* automaticTransitionToNextPlayback= */ false); 149 } 150 } 151 } 152 handlePositionDiscontinuity(eventTime, Player.DISCONTINUITY_REASON_INTERNAL); 153 } 154 155 @Override 156 public synchronized void handlePositionDiscontinuity( 157 EventTime eventTime, @DiscontinuityReason int reason) { 158 Assertions.checkNotNull(listener); 159 boolean hasAutomaticTransition = 160 reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION 161 || reason == Player.DISCONTINUITY_REASON_AD_INSERTION; 162 Iterator<SessionDescriptor> iterator = sessions.values().iterator(); 163 while (iterator.hasNext()) { 164 SessionDescriptor session = iterator.next(); 165 if (session.isFinishedAtEventTime(eventTime)) { 166 iterator.remove(); 167 if (session.isCreated) { 168 boolean isRemovingCurrentSession = session.sessionId.equals(currentSessionId); 169 boolean isAutomaticTransition = 170 hasAutomaticTransition && isRemovingCurrentSession && session.isActive; 171 if (isRemovingCurrentSession) { 172 currentSessionId = null; 173 } 174 listener.onSessionFinished(eventTime, session.sessionId, isAutomaticTransition); 175 } 176 } 177 } 178 @Nullable SessionDescriptor previousSessionDescriptor = sessions.get(currentSessionId); 179 SessionDescriptor currentSessionDescriptor = 180 getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); 181 currentSessionId = currentSessionDescriptor.sessionId; 182 if (eventTime.mediaPeriodId != null 183 && eventTime.mediaPeriodId.isAd() 184 && (previousSessionDescriptor == null 185 || previousSessionDescriptor.windowSequenceNumber 186 != eventTime.mediaPeriodId.windowSequenceNumber 187 || previousSessionDescriptor.adMediaPeriodId == null 188 || previousSessionDescriptor.adMediaPeriodId.adGroupIndex 189 != eventTime.mediaPeriodId.adGroupIndex 190 || previousSessionDescriptor.adMediaPeriodId.adIndexInAdGroup 191 != eventTime.mediaPeriodId.adIndexInAdGroup)) { 192 // New ad playback started. Find corresponding content session and notify ad playback started. 193 MediaPeriodId contentMediaPeriodId = 194 new MediaPeriodId( 195 eventTime.mediaPeriodId.periodUid, eventTime.mediaPeriodId.windowSequenceNumber); 196 SessionDescriptor contentSession = 197 getOrAddSession(eventTime.windowIndex, contentMediaPeriodId); 198 if (contentSession.isCreated && currentSessionDescriptor.isCreated) { 199 listener.onAdPlaybackStarted( 200 eventTime, contentSession.sessionId, currentSessionDescriptor.sessionId); 201 } 202 } 203 } 204 205 @Override 206 public void finishAllSessions(EventTime eventTime) { 207 currentSessionId = null; 208 Iterator<SessionDescriptor> iterator = sessions.values().iterator(); 209 while (iterator.hasNext()) { 210 SessionDescriptor session = iterator.next(); 211 iterator.remove(); 212 if (session.isCreated && listener != null) { 213 listener.onSessionFinished( 214 eventTime, session.sessionId, /* automaticTransitionToNextPlayback= */ false); 215 } 216 } 217 } 218 219 private SessionDescriptor getOrAddSession( 220 int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { 221 // There should only be one matching session if mediaPeriodId is non-null. If mediaPeriodId is 222 // null, there may be multiple matching sessions with different window sequence numbers or 223 // adMediaPeriodIds. The best match is the one with the smaller window sequence number, and for 224 // windows with ads, the content session is preferred over ad sessions. 225 SessionDescriptor bestMatch = null; 226 long bestMatchWindowSequenceNumber = Long.MAX_VALUE; 227 for (SessionDescriptor sessionDescriptor : sessions.values()) { 228 sessionDescriptor.maybeSetWindowSequenceNumber(windowIndex, mediaPeriodId); 229 if (sessionDescriptor.belongsToSession(windowIndex, mediaPeriodId)) { 230 long windowSequenceNumber = sessionDescriptor.windowSequenceNumber; 231 if (windowSequenceNumber == C.INDEX_UNSET 232 || windowSequenceNumber < bestMatchWindowSequenceNumber) { 233 bestMatch = sessionDescriptor; 234 bestMatchWindowSequenceNumber = windowSequenceNumber; 235 } else if (windowSequenceNumber == bestMatchWindowSequenceNumber 236 && Util.castNonNull(bestMatch).adMediaPeriodId != null 237 && sessionDescriptor.adMediaPeriodId != null) { 238 bestMatch = sessionDescriptor; 239 } 240 } 241 } 242 if (bestMatch == null) { 243 String sessionId = sessionIdGenerator.get(); 244 bestMatch = new SessionDescriptor(sessionId, windowIndex, mediaPeriodId); 245 sessions.put(sessionId, bestMatch); 246 } 247 return bestMatch; 248 } 249 250 private static String generateDefaultSessionId() { 251 byte[] randomBytes = new byte[SESSION_ID_LENGTH]; 252 RANDOM.nextBytes(randomBytes); 253 return Base64.encodeToString(randomBytes, Base64.URL_SAFE | Base64.NO_WRAP); 254 } 255 256 /** 257 * Descriptor for a session. 258 * 259 * <p>The session may be described in one of three ways: 260 * 261 * <ul> 262 * <li>A window index with unset window sequence number and a null ad media period id 263 * <li>A content window with index and sequence number, but a null ad media period id. 264 * <li>An ad with all values set. 265 * </ul> 266 */ 267 private final class SessionDescriptor { 268 269 private final String sessionId; 270 271 private int windowIndex; 272 private long windowSequenceNumber; 273 private @MonotonicNonNull MediaPeriodId adMediaPeriodId; 274 275 private boolean isCreated; 276 private boolean isActive; 277 278 public SessionDescriptor( 279 String sessionId, int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { 280 this.sessionId = sessionId; 281 this.windowIndex = windowIndex; 282 this.windowSequenceNumber = 283 mediaPeriodId == null ? C.INDEX_UNSET : mediaPeriodId.windowSequenceNumber; 284 if (mediaPeriodId != null && mediaPeriodId.isAd()) { 285 this.adMediaPeriodId = mediaPeriodId; 286 } 287 } 288 289 public boolean tryResolvingToNewTimeline(Timeline oldTimeline, Timeline newTimeline) { 290 windowIndex = resolveWindowIndexToNewTimeline(oldTimeline, newTimeline, windowIndex); 291 if (windowIndex == C.INDEX_UNSET) { 292 return false; 293 } 294 if (adMediaPeriodId == null) { 295 return true; 296 } 297 int newPeriodIndex = newTimeline.getIndexOfPeriod(adMediaPeriodId.periodUid); 298 return newPeriodIndex != C.INDEX_UNSET; 299 } 300 301 public boolean belongsToSession( 302 int eventWindowIndex, @Nullable MediaPeriodId eventMediaPeriodId) { 303 if (eventMediaPeriodId == null) { 304 // Events without concrete media period id are for all sessions of the same window. 305 return eventWindowIndex == windowIndex; 306 } 307 if (adMediaPeriodId == null) { 308 // If this is a content session, only events for content with the same window sequence 309 // number belong to this session. 310 return !eventMediaPeriodId.isAd() 311 && eventMediaPeriodId.windowSequenceNumber == windowSequenceNumber; 312 } 313 // If this is an ad session, only events for this ad belong to the session. 314 return eventMediaPeriodId.windowSequenceNumber == adMediaPeriodId.windowSequenceNumber 315 && eventMediaPeriodId.adGroupIndex == adMediaPeriodId.adGroupIndex 316 && eventMediaPeriodId.adIndexInAdGroup == adMediaPeriodId.adIndexInAdGroup; 317 } 318 319 public void maybeSetWindowSequenceNumber( 320 int eventWindowIndex, @Nullable MediaPeriodId eventMediaPeriodId) { 321 if (windowSequenceNumber == C.INDEX_UNSET 322 && eventWindowIndex == windowIndex 323 && eventMediaPeriodId != null) { 324 // Set window sequence number for this session as soon as we have one. 325 windowSequenceNumber = eventMediaPeriodId.windowSequenceNumber; 326 } 327 } 328 329 public boolean isFinishedAtEventTime(EventTime eventTime) { 330 if (windowSequenceNumber == C.INDEX_UNSET) { 331 // Sessions with unspecified window sequence number are kept until we know more. 332 return false; 333 } 334 if (eventTime.mediaPeriodId == null) { 335 // For event times without media period id (e.g. after seek to new window), we only keep 336 // sessions of this window. 337 return windowIndex != eventTime.windowIndex; 338 } 339 if (eventTime.mediaPeriodId.windowSequenceNumber > windowSequenceNumber) { 340 // All past window sequence numbers are finished. 341 return true; 342 } 343 if (adMediaPeriodId == null) { 344 // Current or future content is not finished. 345 return false; 346 } 347 int eventPeriodIndex = eventTime.timeline.getIndexOfPeriod(eventTime.mediaPeriodId.periodUid); 348 int adPeriodIndex = eventTime.timeline.getIndexOfPeriod(adMediaPeriodId.periodUid); 349 if (eventTime.mediaPeriodId.windowSequenceNumber < adMediaPeriodId.windowSequenceNumber 350 || eventPeriodIndex < adPeriodIndex) { 351 // Ads in future windows or periods are not finished. 352 return false; 353 } 354 if (eventPeriodIndex > adPeriodIndex) { 355 // Ads in past periods are finished. 356 return true; 357 } 358 if (eventTime.mediaPeriodId.isAd()) { 359 int eventAdGroup = eventTime.mediaPeriodId.adGroupIndex; 360 int eventAdIndex = eventTime.mediaPeriodId.adIndexInAdGroup; 361 // Finished if event is for an ad after this one in the same period. 362 return eventAdGroup > adMediaPeriodId.adGroupIndex 363 || (eventAdGroup == adMediaPeriodId.adGroupIndex 364 && eventAdIndex > adMediaPeriodId.adIndexInAdGroup); 365 } else { 366 // Finished if the event is for content after this ad. 367 return eventTime.mediaPeriodId.nextAdGroupIndex == C.INDEX_UNSET 368 || eventTime.mediaPeriodId.nextAdGroupIndex > adMediaPeriodId.adGroupIndex; 369 } 370 } 371 resolveWindowIndexToNewTimeline( Timeline oldTimeline, Timeline newTimeline, int windowIndex)372 private int resolveWindowIndexToNewTimeline( 373 Timeline oldTimeline, Timeline newTimeline, int windowIndex) { 374 if (windowIndex >= oldTimeline.getWindowCount()) { 375 return windowIndex < newTimeline.getWindowCount() ? windowIndex : C.INDEX_UNSET; 376 } 377 oldTimeline.getWindow(windowIndex, window); 378 for (int periodIndex = window.firstPeriodIndex; 379 periodIndex <= window.lastPeriodIndex; 380 periodIndex++) { 381 Object periodUid = oldTimeline.getUidOfPeriod(periodIndex); 382 int newPeriodIndex = newTimeline.getIndexOfPeriod(periodUid); 383 if (newPeriodIndex != C.INDEX_UNSET) { 384 return newTimeline.getPeriod(newPeriodIndex, period).windowIndex; 385 } 386 } 387 return C.INDEX_UNSET; 388 } 389 } 390 } 391