From 343279b21391dc285230e7a2f93b9d3c39ea393b Mon Sep 17 00:00:00 2001 From: tonihei Date: Mon, 22 Nov 2021 11:47:26 +0000 Subject: [PATCH] Add MediaMetricsListener class. PiperOrigin-RevId: 411517319 --- .../analytics/MediaMetricsListener.java | 847 ++++++++++++++++++ 1 file changed, 847 insertions(+) create mode 100644 libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/MediaMetricsListener.java diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/MediaMetricsListener.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/MediaMetricsListener.java new file mode 100644 index 0000000000..0ebad570b5 --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/analytics/MediaMetricsListener.java @@ -0,0 +1,847 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.analytics; + +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkStateNotNull; +import static androidx.media3.common.util.Util.castNonNull; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.media.DeniedByServerException; +import android.media.MediaCodec; +import android.media.MediaDrm; +import android.media.MediaDrmResetException; +import android.media.NotProvisionedException; +import android.media.metrics.LogSessionId; +import android.media.metrics.MediaMetricsManager; +import android.media.metrics.NetworkEvent; +import android.media.metrics.PlaybackErrorEvent; +import android.media.metrics.PlaybackMetrics; +import android.media.metrics.PlaybackSession; +import android.media.metrics.PlaybackStateEvent; +import android.media.metrics.TrackChangeEvent; +import android.os.SystemClock; +import android.system.ErrnoException; +import android.system.OsConstants; +import android.util.Pair; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.media3.common.C; +import androidx.media3.common.DrmInitData; +import androidx.media3.common.Format; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MediaLibraryInfo; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.ParserException; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.Player; +import androidx.media3.common.Timeline; +import androidx.media3.common.TrackGroup; +import androidx.media3.common.TracksInfo; +import androidx.media3.common.TracksInfo.TrackGroupInfo; +import androidx.media3.common.VideoSize; +import androidx.media3.common.util.NetworkTypeObserver; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import androidx.media3.datasource.FileDataSource; +import androidx.media3.datasource.HttpDataSource; +import androidx.media3.datasource.UdpDataSource; +import androidx.media3.exoplayer.DecoderCounters; +import androidx.media3.exoplayer.ExoPlaybackException; +import androidx.media3.exoplayer.audio.AudioSink; +import androidx.media3.exoplayer.drm.DefaultDrmSessionManager; +import androidx.media3.exoplayer.drm.DrmSession; +import androidx.media3.exoplayer.drm.UnsupportedDrmException; +import androidx.media3.exoplayer.mediacodec.MediaCodecDecoderException; +import androidx.media3.exoplayer.mediacodec.MediaCodecRenderer; +import androidx.media3.exoplayer.source.LoadEventInfo; +import androidx.media3.exoplayer.source.MediaLoadData; +import androidx.media3.exoplayer.source.MediaSource; +import com.google.common.collect.ImmutableList; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.util.UUID; +import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * An {@link AnalyticsListener} that interacts with the Android {@link MediaMetricsManager}. + * + *

It listens to playback events and forwards them to a {@link PlaybackSession}. The {@link + * LogSessionId} of the playback session can be obtained with {@link #getLogSessionId()}. + */ +@UnstableApi +@RequiresApi(31) +public final class MediaMetricsListener + implements AnalyticsListener, PlaybackSessionManager.Listener { + + private final Context context; + private final PlaybackSessionManager sessionManager; + private final PlaybackSession playbackSession; + private final long startTimeMs; + private final Timeline.Window window; + private final Timeline.Period period; + + @Nullable private PlaybackMetrics.Builder metricsBuilder; + @Player.DiscontinuityReason private int discontinuityReason; + private int currentPlaybackState; + private int currentNetworkType; + @Nullable private PlaybackException pendingPlayerError; + @Nullable private PendingFormatUpdate pendingVideoFormat; + @Nullable private PendingFormatUpdate pendingAudioFormat; + @Nullable private PendingFormatUpdate pendingTextFormat; + @Nullable private Format currentVideoFormat; + @Nullable private Format currentAudioFormat; + @Nullable private Format currentTextFormat; + private boolean isSeeking; + private int ioErrorType; + private boolean hasFatalError; + private int droppedFrames; + private int playedFrames; + private long bandwidthTimeMs; + private long bandwidthBytes; + private int audioUnderruns; + + /** + * Creates the listener. + * + * @param context A {@link Context}. + */ + public MediaMetricsListener(Context context) { + context = context.getApplicationContext(); + this.context = context; + window = new Timeline.Window(); + period = new Timeline.Period(); + MediaMetricsManager mediaMetricsManager = + checkStateNotNull( + (MediaMetricsManager) context.getSystemService(Context.MEDIA_METRICS_SERVICE)); + playbackSession = mediaMetricsManager.createPlaybackSession(); + startTimeMs = SystemClock.elapsedRealtime(); + currentPlaybackState = PlaybackStateEvent.STATE_NOT_STARTED; + currentNetworkType = NetworkEvent.NETWORK_TYPE_UNKNOWN; + sessionManager = new DefaultPlaybackSessionManager(); + sessionManager.setListener(this); + } + + /** Returns the {@link LogSessionId} used by this listener. */ + public LogSessionId getLogSessionId() { + return playbackSession.getSessionId(); + } + + // PlaybackSessionManager.Listener implementation. + + @Override + public void onSessionCreated(EventTime eventTime, String sessionId) {} + + @Override + public void onSessionActive(EventTime eventTime, String sessionId) { + if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) { + // Ignore ad sessions. + return; + } + finishCurrentSession(); + metricsBuilder = + new PlaybackMetrics.Builder() + .setPlayerName(MediaLibraryInfo.TAG) + .setPlayerVersion(MediaLibraryInfo.VERSION); + maybeUpdateTimelineMetadata(eventTime.timeline, eventTime.mediaPeriodId); + } + + @Override + public void onAdPlaybackStarted( + EventTime eventTime, String contentSessionId, String adSessionId) {} + + @Override + public void onSessionFinished( + EventTime eventTime, String sessionId, boolean automaticTransitionToNextPlayback) { + if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) { + // Ignore ad sessions. + return; + } + finishCurrentSession(); + } + + // AnalyticsListener implementation. + + @Override + public void onPositionDiscontinuity( + EventTime eventTime, + Player.PositionInfo oldPosition, + Player.PositionInfo newPosition, + @Player.DiscontinuityReason int reason) { + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + isSeeking = true; + } + discontinuityReason = reason; + } + + @Override + public void onVideoDisabled(EventTime eventTime, DecoderCounters decoderCounters) { + // TODO(b/181122234): DecoderCounters are not re-reported at period boundaries. + droppedFrames += decoderCounters.droppedBufferCount; + playedFrames += decoderCounters.renderedOutputBufferCount; + } + + @Override + public void onBandwidthEstimate( + EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) { + bandwidthTimeMs += totalLoadTimeMs; + bandwidthBytes += totalBytesLoaded; + } + + @Override + public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) { + PendingFormatUpdate update = + new PendingFormatUpdate( + checkNotNull(mediaLoadData.trackFormat), + mediaLoadData.trackSelectionReason, + sessionManager.getSessionForMediaPeriodId( + eventTime.timeline, checkNotNull(eventTime.mediaPeriodId))); + switch (mediaLoadData.trackType) { + case C.TRACK_TYPE_VIDEO: + case C.TRACK_TYPE_DEFAULT: + pendingVideoFormat = update; + break; + case C.TRACK_TYPE_AUDIO: + pendingAudioFormat = update; + break; + case C.TRACK_TYPE_TEXT: + pendingTextFormat = update; + break; + default: + // Other track type. Ignore. + } + } + + @Override + public void onVideoSizeChanged(EventTime eventTime, VideoSize videoSize) { + @Nullable PendingFormatUpdate pendingVideoFormat = this.pendingVideoFormat; + if (pendingVideoFormat != null && pendingVideoFormat.format.height == Format.NO_VALUE) { + Format formatWithHeightAndWidth = + pendingVideoFormat + .format + .buildUpon() + .setWidth(videoSize.width) + .setHeight(videoSize.height) + .build(); + this.pendingVideoFormat = + new PendingFormatUpdate( + formatWithHeightAndWidth, + pendingVideoFormat.selectionReason, + pendingVideoFormat.sessionId); + } + } + + @Override + public void onLoadError( + EventTime eventTime, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) { + ioErrorType = mediaLoadData.dataType; + } + + @Override + public void onPlayerError(EventTime eventTime, PlaybackException error) { + pendingPlayerError = error; + } + + @Override + public void onEvents(Player player, Events events) { + if (events.size() == 0) { + return; + } + maybeAddSessions(events); + + long realtimeMs = SystemClock.elapsedRealtime(); + maybeUpdateMetricsBuilderValues(player, events); + maybeReportPlaybackError(realtimeMs); + maybeReportTrackChanges(player, events, realtimeMs); + maybeReportNetworkChange(realtimeMs); + maybeReportPlaybackStateChange(player, events, realtimeMs); + + if (events.contains(AnalyticsListener.EVENT_PLAYER_RELEASED)) { + sessionManager.finishAllSessions(events.getEventTime(EVENT_PLAYER_RELEASED)); + } + } + + private void maybeAddSessions(Events events) { + for (int i = 0; i < events.size(); i++) { + @EventFlags int event = events.get(i); + EventTime eventTime = events.getEventTime(event); + if (event == EVENT_TIMELINE_CHANGED) { + sessionManager.updateSessionsWithTimelineChange(eventTime); + } else if (event == EVENT_POSITION_DISCONTINUITY) { + sessionManager.updateSessionsWithDiscontinuity(eventTime, discontinuityReason); + } else { + sessionManager.updateSessions(eventTime); + } + } + } + + private void maybeUpdateMetricsBuilderValues(Player player, Events events) { + if (events.contains(EVENT_TIMELINE_CHANGED)) { + EventTime eventTime = events.getEventTime(EVENT_TIMELINE_CHANGED); + if (metricsBuilder != null) { + maybeUpdateTimelineMetadata(eventTime.timeline, eventTime.mediaPeriodId); + } + } + if (events.contains(EVENT_TRACKS_CHANGED) && metricsBuilder != null) { + @Nullable + DrmInitData drmInitData = getDrmInitData(player.getCurrentTracksInfo().getTrackGroupInfos()); + if (drmInitData != null) { + castNonNull(metricsBuilder).setDrmType(getDrmType(drmInitData)); + } + } + if (events.contains(EVENT_AUDIO_UNDERRUN)) { + audioUnderruns++; + } + } + + private void maybeReportPlaybackError(long realtimeMs) { + @Nullable PlaybackException error = pendingPlayerError; + if (error == null) { + return; + } + ErrorInfo errorInfo = + getErrorInfo( + error, context, /* lastIoErrorForManifest= */ ioErrorType == C.DATA_TYPE_MANIFEST); + playbackSession.reportPlaybackErrorEvent( + new PlaybackErrorEvent.Builder() + .setTimeSinceCreatedMillis(realtimeMs - startTimeMs) + .setErrorCode(errorInfo.errorCode) + .setSubErrorCode(errorInfo.subErrorCode) + .setException(error) + .build()); + pendingPlayerError = null; + } + + private void maybeReportTrackChanges(Player player, Events events, long realtimeMs) { + if (events.contains(EVENT_TRACKS_CHANGED)) { + TracksInfo tracksInfo = player.getCurrentTracksInfo(); + boolean isVideoSelected = tracksInfo.isTypeSelected(C.TRACK_TYPE_VIDEO); + boolean isAudioSelected = tracksInfo.isTypeSelected(C.TRACK_TYPE_AUDIO); + boolean isTextSelected = tracksInfo.isTypeSelected(C.TRACK_TYPE_TEXT); + if (isVideoSelected || isAudioSelected || isTextSelected) { + // Ignore updates with insufficient information where no tracks are selected. + if (!isVideoSelected) { + maybeUpdateVideoFormat(realtimeMs, /* videoFormat= */ null, C.SELECTION_REASON_UNKNOWN); + } + if (!isAudioSelected) { + maybeUpdateAudioFormat(realtimeMs, /* audioFormat= */ null, C.SELECTION_REASON_UNKNOWN); + } + if (!isTextSelected) { + maybeUpdateTextFormat(realtimeMs, /* textFormat= */ null, C.SELECTION_REASON_UNKNOWN); + } + } + } + if (canReportPendingFormatUpdate(pendingVideoFormat) + && pendingVideoFormat.format.height != Format.NO_VALUE) { + maybeUpdateVideoFormat( + realtimeMs, pendingVideoFormat.format, pendingVideoFormat.selectionReason); + pendingVideoFormat = null; + } + if (canReportPendingFormatUpdate(pendingAudioFormat)) { + maybeUpdateAudioFormat( + realtimeMs, pendingAudioFormat.format, pendingAudioFormat.selectionReason); + pendingAudioFormat = null; + } + if (canReportPendingFormatUpdate(pendingTextFormat)) { + maybeUpdateTextFormat( + realtimeMs, pendingTextFormat.format, pendingTextFormat.selectionReason); + pendingTextFormat = null; + } + } + + @EnsuresNonNullIf(result = true, expression = "#1") + private boolean canReportPendingFormatUpdate(@Nullable PendingFormatUpdate pendingFormatUpdate) { + return pendingFormatUpdate != null + && pendingFormatUpdate.sessionId.equals(sessionManager.getActiveSessionId()); + } + + private void maybeReportNetworkChange(long realtimeMs) { + int networkType = getNetworkType(context); + if (networkType != currentNetworkType) { + currentNetworkType = networkType; + playbackSession.reportNetworkEvent( + new NetworkEvent.Builder() + .setNetworkType(networkType) + .setTimeSinceCreatedMillis(realtimeMs - startTimeMs) + .build()); + } + } + + private void maybeReportPlaybackStateChange(Player player, Events events, long realtimeMs) { + if (player.getPlaybackState() != Player.STATE_BUFFERING) { + isSeeking = false; + } + if (player.getPlayerError() == null) { + hasFatalError = false; + } else if (events.contains(EVENT_PLAYER_ERROR)) { + hasFatalError = true; + } + int newPlaybackState = resolveNewPlaybackState(player); + if (currentPlaybackState != newPlaybackState) { + currentPlaybackState = newPlaybackState; + playbackSession.reportPlaybackStateEvent( + new PlaybackStateEvent.Builder() + .setState(currentPlaybackState) + .setTimeSinceCreatedMillis(realtimeMs - startTimeMs) + .build()); + } + } + + private int resolveNewPlaybackState(Player player) { + @Player.State int playerPlaybackState = player.getPlaybackState(); + if (isSeeking) { + // Seeking takes precedence over errors such that we report a seek while in error state. + return PlaybackStateEvent.STATE_SEEKING; + } else if (hasFatalError) { + return PlaybackStateEvent.STATE_FAILED; + } else if (playerPlaybackState == Player.STATE_ENDED) { + return PlaybackStateEvent.STATE_ENDED; + } else if (playerPlaybackState == Player.STATE_BUFFERING) { + if (currentPlaybackState == PlaybackStateEvent.STATE_NOT_STARTED + || currentPlaybackState == PlaybackStateEvent.STATE_JOINING_FOREGROUND) { + return PlaybackStateEvent.STATE_JOINING_FOREGROUND; + } + if (!player.getPlayWhenReady()) { + return PlaybackStateEvent.STATE_PAUSED_BUFFERING; + } + return player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE + ? PlaybackStateEvent.STATE_SUPPRESSED_BUFFERING + : PlaybackStateEvent.STATE_BUFFERING; + } else if (playerPlaybackState == Player.STATE_READY) { + if (!player.getPlayWhenReady()) { + return PlaybackStateEvent.STATE_PAUSED; + } + return player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE + ? PlaybackStateEvent.STATE_SUPPRESSED + : PlaybackStateEvent.STATE_PLAYING; + } else if (playerPlaybackState == Player.STATE_IDLE + && currentPlaybackState != PlaybackStateEvent.STATE_NOT_STARTED) { + // This case only applies for calls to player.stop(). All other IDLE cases are handled by + // !isForeground, hasFatalError or isSuspended. NOT_STARTED is deliberately ignored. + return PlaybackStateEvent.STATE_STOPPED; + } + return currentPlaybackState; + } + + private void maybeUpdateVideoFormat( + long realtimeMs, @Nullable Format videoFormat, @C.SelectionReason int trackSelectionReason) { + if (Util.areEqual(currentVideoFormat, videoFormat)) { + return; + } + if (currentVideoFormat == null && trackSelectionReason == C.SELECTION_REASON_UNKNOWN) { + trackSelectionReason = C.SELECTION_REASON_INITIAL; + } + currentVideoFormat = videoFormat; + reportTrackChangeEvent( + TrackChangeEvent.TRACK_TYPE_VIDEO, realtimeMs, videoFormat, trackSelectionReason); + } + + private void maybeUpdateAudioFormat( + long realtimeMs, @Nullable Format audioFormat, @C.SelectionReason int trackSelectionReason) { + if (Util.areEqual(currentAudioFormat, audioFormat)) { + return; + } + if (currentAudioFormat == null && trackSelectionReason == C.SELECTION_REASON_UNKNOWN) { + trackSelectionReason = C.SELECTION_REASON_INITIAL; + } + currentAudioFormat = audioFormat; + reportTrackChangeEvent( + TrackChangeEvent.TRACK_TYPE_AUDIO, realtimeMs, audioFormat, trackSelectionReason); + } + + private void maybeUpdateTextFormat( + long realtimeMs, @Nullable Format textFormat, @C.SelectionReason int trackSelectionReason) { + if (Util.areEqual(currentTextFormat, textFormat)) { + return; + } + if (currentTextFormat == null && trackSelectionReason == C.SELECTION_REASON_UNKNOWN) { + trackSelectionReason = C.SELECTION_REASON_INITIAL; + } + currentTextFormat = textFormat; + reportTrackChangeEvent( + TrackChangeEvent.TRACK_TYPE_TEXT, realtimeMs, textFormat, trackSelectionReason); + } + + private void reportTrackChangeEvent( + int type, + long realtimeMs, + @Nullable Format format, + @C.SelectionReason int trackSelectionReason) { + TrackChangeEvent.Builder builder = + new TrackChangeEvent.Builder(type).setTimeSinceCreatedMillis(realtimeMs - startTimeMs); + if (format != null) { + builder.setTrackState(TrackChangeEvent.TRACK_STATE_ON); + builder.setTrackChangeReason(getTrackChangeReason(trackSelectionReason)); + if (format.containerMimeType != null) { + // TODO(b/181121074): Progressive container mime type is not filled in by MediaSource. + builder.setContainerMimeType(format.containerMimeType); + } + if (format.sampleMimeType != null) { + builder.setSampleMimeType(format.sampleMimeType); + } + if (format.codecs != null) { + builder.setCodecName(format.codecs); + } + if (format.bitrate != Format.NO_VALUE) { + builder.setBitrate(format.bitrate); + } + if (format.width != Format.NO_VALUE) { + builder.setWidth(format.width); + } + if (format.height != Format.NO_VALUE) { + builder.setHeight(format.height); + } + if (format.channelCount != Format.NO_VALUE) { + builder.setChannelCount(format.channelCount); + } + if (format.sampleRate != Format.NO_VALUE) { + builder.setAudioSampleRate(format.sampleRate); + } + if (format.language != null) { + Pair languageAndRegion = + getLanguageAndRegion(format.language); + builder.setLanguage(languageAndRegion.first); + if (languageAndRegion.second != null) { + builder.setLanguageRegion(languageAndRegion.second); + } + } + if (format.frameRate != Format.NO_VALUE) { + builder.setVideoFrameRate(format.frameRate); + } + } else { + builder.setTrackState(TrackChangeEvent.TRACK_STATE_OFF); + } + playbackSession.reportTrackChangeEvent(builder.build()); + } + + @RequiresNonNull("metricsBuilder") + private void maybeUpdateTimelineMetadata( + Timeline timeline, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { + PlaybackMetrics.Builder metricsBuilder = this.metricsBuilder; + if (mediaPeriodId == null) { + return; + } + int periodIndex = timeline.getIndexOfPeriod(mediaPeriodId.periodUid); + if (periodIndex == C.INDEX_UNSET) { + return; + } + timeline.getPeriod(periodIndex, period); + timeline.getWindow(period.windowIndex, window); + metricsBuilder.setStreamType(getStreamType(window.mediaItem)); + if (window.durationUs != C.TIME_UNSET + && !window.isPlaceholder + && !window.isDynamic + && !window.isLive()) { + metricsBuilder.setMediaDurationMillis(window.getDurationMs()); + } + metricsBuilder.setPlaybackType( + window.isLive() ? PlaybackMetrics.PLAYBACK_TYPE_LIVE : PlaybackMetrics.PLAYBACK_TYPE_VOD); + } + + private void finishCurrentSession() { + if (metricsBuilder == null) { + return; + } + metricsBuilder.setAudioUnderrunCount(audioUnderruns); + metricsBuilder.setVideoFramesDropped(droppedFrames); + metricsBuilder.setVideoFramesPlayed(playedFrames); + metricsBuilder.setNetworkTransferDurationMillis(bandwidthTimeMs); + // TODO(b/181121847): Report localBytesRead. This requires additional callbacks or plumbing. + metricsBuilder.setNetworkBytesRead(bandwidthBytes); + // TODO(b/181121847): Detect stream sources mixed and local depending on localBytesRead. + metricsBuilder.setStreamSource( + bandwidthBytes > 0 + ? PlaybackMetrics.STREAM_SOURCE_NETWORK + : PlaybackMetrics.STREAM_SOURCE_UNKNOWN); + playbackSession.reportPlaybackMetrics(metricsBuilder.build()); + metricsBuilder = null; + } + + private static int getTrackChangeReason(@C.SelectionReason int trackSelectionReason) { + switch (trackSelectionReason) { + case C.SELECTION_REASON_INITIAL: + return TrackChangeEvent.TRACK_CHANGE_REASON_INITIAL; + case C.SELECTION_REASON_ADAPTIVE: + return TrackChangeEvent.TRACK_CHANGE_REASON_ADAPTIVE; + case C.SELECTION_REASON_MANUAL: + return TrackChangeEvent.TRACK_CHANGE_REASON_MANUAL; + case C.SELECTION_REASON_TRICK_PLAY: + case C.SELECTION_REASON_UNKNOWN: + default: + return TrackChangeEvent.TRACK_CHANGE_REASON_OTHER; + } + } + + private static Pair getLanguageAndRegion(String languageCode) { + String[] parts = Util.split(languageCode, "-"); + return Pair.create(parts[0], parts.length >= 2 ? parts[1] : null); + } + + private static int getNetworkType(Context context) { + switch (NetworkTypeObserver.getInstance(context).getNetworkType()) { + case C.NETWORK_TYPE_WIFI: + return NetworkEvent.NETWORK_TYPE_WIFI; + case C.NETWORK_TYPE_2G: + return NetworkEvent.NETWORK_TYPE_2G; + case C.NETWORK_TYPE_3G: + return NetworkEvent.NETWORK_TYPE_3G; + case C.NETWORK_TYPE_4G: + return NetworkEvent.NETWORK_TYPE_4G; + case C.NETWORK_TYPE_5G_SA: + return NetworkEvent.NETWORK_TYPE_5G_SA; + case C.NETWORK_TYPE_5G_NSA: + return NetworkEvent.NETWORK_TYPE_5G_NSA; + case C.NETWORK_TYPE_ETHERNET: + return NetworkEvent.NETWORK_TYPE_ETHERNET; + case C.NETWORK_TYPE_OFFLINE: + return NetworkEvent.NETWORK_TYPE_OFFLINE; + case C.NETWORK_TYPE_UNKNOWN: + return NetworkEvent.NETWORK_TYPE_UNKNOWN; + default: + return NetworkEvent.NETWORK_TYPE_OTHER; + } + } + + private static int getStreamType(MediaItem mediaItem) { + if (mediaItem.localConfiguration == null || mediaItem.localConfiguration.mimeType == null) { + return PlaybackMetrics.STREAM_TYPE_UNKNOWN; + } + String mimeType = mediaItem.localConfiguration.mimeType; + switch (mimeType) { + case MimeTypes.APPLICATION_M3U8: + return PlaybackMetrics.STREAM_TYPE_HLS; + case MimeTypes.APPLICATION_MPD: + return PlaybackMetrics.STREAM_TYPE_DASH; + case MimeTypes.APPLICATION_SS: + return PlaybackMetrics.STREAM_TYPE_SS; + default: + return PlaybackMetrics.STREAM_TYPE_PROGRESSIVE; + } + } + + private static ErrorInfo getErrorInfo( + PlaybackException error, Context context, boolean lastIoErrorForManifest) { + if (error.errorCode == PlaybackException.ERROR_CODE_REMOTE_ERROR) { + return new ErrorInfo(PlaybackErrorEvent.ERROR_PLAYER_REMOTE, /* subErrorCode= */ 0); + } + // Unpack the PlaybackException. + // TODO(b/190203080): Use error codes instead of the Exception's cause where possible. + boolean isRendererExoPlaybackException = false; + int rendererFormatSupport = C.FORMAT_UNSUPPORTED_TYPE; + if (error instanceof ExoPlaybackException) { + ExoPlaybackException exoPlaybackException = (ExoPlaybackException) error; + isRendererExoPlaybackException = + exoPlaybackException.type == ExoPlaybackException.TYPE_RENDERER; + rendererFormatSupport = exoPlaybackException.rendererFormatSupport; + } + Throwable cause = checkNotNull(error.getCause()); + if (cause instanceof IOException) { + if (cause instanceof HttpDataSource.InvalidResponseCodeException) { + int responseCode = ((HttpDataSource.InvalidResponseCodeException) cause).responseCode; + return new ErrorInfo( + PlaybackErrorEvent.ERROR_IO_BAD_HTTP_STATUS, /* subErrorCode= */ responseCode); + } else if (cause instanceof HttpDataSource.InvalidContentTypeException + || cause instanceof ParserException) { + return new ErrorInfo( + lastIoErrorForManifest + ? PlaybackErrorEvent.ERROR_PARSING_MANIFEST_MALFORMED + : PlaybackErrorEvent.ERROR_PARSING_CONTAINER_MALFORMED, + /* subErrorCode= */ 0); + } else if (cause instanceof HttpDataSource.HttpDataSourceException + || cause instanceof UdpDataSource.UdpDataSourceException) { + if (NetworkTypeObserver.getInstance(context).getNetworkType() == C.NETWORK_TYPE_OFFLINE) { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_IO_NETWORK_UNAVAILABLE, /* subErrorCode= */ 0); + } else { + @Nullable Throwable detailedCause = cause.getCause(); + if (detailedCause instanceof UnknownHostException) { + return new ErrorInfo(PlaybackErrorEvent.ERROR_IO_DNS_FAILED, /* subErrorCode= */ 0); + } else if (detailedCause instanceof SocketTimeoutException) { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_IO_CONNECTION_TIMEOUT, /* subErrorCode= */ 0); + } else if (cause instanceof HttpDataSource.HttpDataSourceException + && ((HttpDataSource.HttpDataSourceException) cause).type + == HttpDataSource.HttpDataSourceException.TYPE_OPEN) { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_IO_NETWORK_CONNECTION_FAILED, /* subErrorCode= */ 0); + } else { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_IO_CONNECTION_CLOSED, /* subErrorCode= */ 0); + } + } + } else if (error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW) { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_PLAYER_BEHIND_LIVE_WINDOW, /* subErrorCode= */ 0); + } else if (cause instanceof DrmSession.DrmSessionException) { + // Unpack DrmSessionException. + cause = checkNotNull(cause.getCause()); + if (Util.SDK_INT >= 21 && cause instanceof MediaDrm.MediaDrmStateException) { + String diagnosticsInfo = ((MediaDrm.MediaDrmStateException) cause).getDiagnosticInfo(); + int subErrorCode = Util.getErrorCodeFromPlatformDiagnosticsInfo(diagnosticsInfo); + int errorCode = getDrmErrorCode(subErrorCode); + return new ErrorInfo(errorCode, subErrorCode); + } else if (Util.SDK_INT >= 23 && cause instanceof MediaDrmResetException) { + return new ErrorInfo(PlaybackErrorEvent.ERROR_DRM_SYSTEM_ERROR, /* subErrorCode= */ 0); + } else if (Util.SDK_INT >= 18 && cause instanceof NotProvisionedException) { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_DRM_PROVISIONING_FAILED, /* subErrorCode= */ 0); + } else if (Util.SDK_INT >= 18 && cause instanceof DeniedByServerException) { + return new ErrorInfo(PlaybackErrorEvent.ERROR_DRM_DEVICE_REVOKED, /* subErrorCode= */ 0); + } else if (cause instanceof UnsupportedDrmException) { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_DRM_SCHEME_UNSUPPORTED, /* subErrorCode= */ 0); + } else if (cause instanceof DefaultDrmSessionManager.MissingSchemeDataException) { + return new ErrorInfo(PlaybackErrorEvent.ERROR_DRM_CONTENT_ERROR, /* subErrorCode= */ 0); + } else { + return new ErrorInfo(PlaybackErrorEvent.ERROR_DRM_OTHER, /* subErrorCode= */ 0); + } + } else if (cause instanceof FileDataSource.FileDataSourceException + && cause.getCause() instanceof FileNotFoundException) { + @Nullable Throwable notFoundCause = checkNotNull(cause.getCause()).getCause(); + if (Util.SDK_INT >= 21 + && notFoundCause instanceof ErrnoException + && ((ErrnoException) notFoundCause).errno == OsConstants.EACCES) { + return new ErrorInfo(PlaybackErrorEvent.ERROR_IO_NO_PERMISSION, /* subErrorCode= */ 0); + } else { + return new ErrorInfo(PlaybackErrorEvent.ERROR_IO_FILE_NOT_FOUND, /* subErrorCode= */ 0); + } + } else { + return new ErrorInfo(PlaybackErrorEvent.ERROR_IO_OTHER, /* subErrorCode= */ 0); + } + } else if (isRendererExoPlaybackException + && (rendererFormatSupport == C.FORMAT_UNSUPPORTED_TYPE + || rendererFormatSupport == C.FORMAT_UNSUPPORTED_SUBTYPE)) { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_DECODING_FORMAT_UNSUPPORTED, /* subErrorCode= */ 0); + } else if (isRendererExoPlaybackException + && rendererFormatSupport == C.FORMAT_EXCEEDS_CAPABILITIES) { + return new ErrorInfo( + PlaybackErrorEvent.ERROR_DECODING_FORMAT_EXCEEDS_CAPABILITIES, /* subErrorCode= */ 0); + } else if (isRendererExoPlaybackException + && rendererFormatSupport == C.FORMAT_UNSUPPORTED_DRM) { + return new ErrorInfo(PlaybackErrorEvent.ERROR_DRM_SCHEME_UNSUPPORTED, /* subErrorCode= */ 0); + } else if (cause instanceof MediaCodecRenderer.DecoderInitializationException) { + @Nullable + String diagnosticsInfo = + ((MediaCodecRenderer.DecoderInitializationException) cause).diagnosticInfo; + int subErrorCode = Util.getErrorCodeFromPlatformDiagnosticsInfo(diagnosticsInfo); + return new ErrorInfo(PlaybackErrorEvent.ERROR_DECODER_INIT_FAILED, subErrorCode); + } else if (cause instanceof MediaCodecDecoderException) { + @Nullable String diagnosticsInfo = ((MediaCodecDecoderException) cause).diagnosticInfo; + int subErrorCode = Util.getErrorCodeFromPlatformDiagnosticsInfo(diagnosticsInfo); + return new ErrorInfo(PlaybackErrorEvent.ERROR_DECODING_FAILED, subErrorCode); + } else if (cause instanceof OutOfMemoryError) { + return new ErrorInfo(PlaybackErrorEvent.ERROR_DECODING_FAILED, /* subErrorCode= */ 0); + } else if (cause instanceof AudioSink.InitializationException) { + int subErrorCode = ((AudioSink.InitializationException) cause).audioTrackState; + return new ErrorInfo(PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, subErrorCode); + } else if (cause instanceof AudioSink.WriteException) { + int subErrorCode = ((AudioSink.WriteException) cause).errorCode; + return new ErrorInfo(PlaybackErrorEvent.ERROR_AUDIO_TRACK_WRITE_FAILED, subErrorCode); + } else if (Util.SDK_INT >= 16 && cause instanceof MediaCodec.CryptoException) { + int subErrorCode = ((MediaCodec.CryptoException) cause).getErrorCode(); + int errorCode = getDrmErrorCode(subErrorCode); + return new ErrorInfo(errorCode, subErrorCode); + } else { + return new ErrorInfo(PlaybackErrorEvent.ERROR_PLAYER_OTHER, /* subErrorCode= */ 0); + } + } + + @Nullable + private static DrmInitData getDrmInitData(ImmutableList trackGroupInfos) { + for (TrackGroupInfo trackGroupInfo : trackGroupInfos) { + TrackGroup trackGroup = trackGroupInfo.getTrackGroup(); + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + if (trackGroupInfo.isTrackSelected(trackIndex)) { + @Nullable DrmInitData drmInitData = trackGroup.getFormat(trackIndex).drmInitData; + if (drmInitData != null) { + return drmInitData; + } + } + } + } + return null; + } + + private static int getDrmType(DrmInitData drmInitData) { + for (int i = 0; i < drmInitData.schemeDataCount; i++) { + UUID uuid = drmInitData.get(i).uuid; + if (uuid.equals(C.WIDEVINE_UUID)) { + // TODO(b/77625596): Forward MediaDrm metrics to distinguish between L1 and L3 and to set + // the drm session id. + return PlaybackMetrics.DRM_TYPE_WIDEVINE_L1; + } + if (uuid.equals(C.PLAYREADY_UUID)) { + return PlaybackMetrics.DRM_TYPE_PLAY_READY; + } + if (uuid.equals(C.CLEARKEY_UUID)) { + return PlaybackMetrics.DRM_TYPE_CLEARKEY; + } + } + return PlaybackMetrics.DRM_TYPE_OTHER; + } + + @SuppressLint("SwitchIntDef") // Only DRM error codes are relevant here. + private static int getDrmErrorCode(int mediaDrmErrorCode) { + switch (Util.getErrorCodeForMediaDrmErrorCode(mediaDrmErrorCode)) { + case PlaybackException.ERROR_CODE_DRM_PROVISIONING_FAILED: + return PlaybackErrorEvent.ERROR_DRM_PROVISIONING_FAILED; + case PlaybackException.ERROR_CODE_DRM_LICENSE_ACQUISITION_FAILED: + return PlaybackErrorEvent.ERROR_DRM_LICENSE_ACQUISITION_FAILED; + case PlaybackException.ERROR_CODE_DRM_DISALLOWED_OPERATION: + return PlaybackErrorEvent.ERROR_DRM_DISALLOWED_OPERATION; + case PlaybackException.ERROR_CODE_DRM_CONTENT_ERROR: + return PlaybackErrorEvent.ERROR_DRM_CONTENT_ERROR; + case PlaybackException.ERROR_CODE_DRM_SYSTEM_ERROR: + default: + return PlaybackErrorEvent.ERROR_DRM_SYSTEM_ERROR; + } + } + + private static final class ErrorInfo { + + public final int errorCode; + public final int subErrorCode; + + public ErrorInfo(int errorCode, int subErrorCode) { + this.errorCode = errorCode; + this.subErrorCode = subErrorCode; + } + } + + private static final class PendingFormatUpdate { + + public final Format format; + @C.SelectionReason public final int selectionReason; + public final String sessionId; + + public PendingFormatUpdate( + Format format, @C.SelectionReason int selectionReason, String sessionId) { + this.format = format; + this.selectionReason = selectionReason; + this.sessionId = sessionId; + } + } +}