public final class

CmcdData

extends java.lang.Object

 java.lang.Object

↳androidx.media3.exoplayer.upstream.CmcdData

Gradle dependencies

compile group: 'androidx.media3', name: 'media3-exoplayer', version: '1.5.0-alpha01'

  • groupId: androidx.media3
  • artifactId: media3-exoplayer
  • version: 1.5.0-alpha01

Artifact androidx.media3:media3-exoplayer:1.5.0-alpha01 it located at Google repository (https://maven.google.com/)

Overview

This class provides functionality for generating and adding Common Media Client Data (CMCD) data to adaptive streaming formats, DASH, HLS, and SmoothStreaming.

It encapsulates the necessary attributes and information relevant to media content playback, following the guidelines specified in the CMCD standard document CTA-5004.

Summary

Methods
public DataSpecaddToDataSpec(DataSpec dataSpec)

Adds Common Media Client Data (CMCD) related information to the provided DataSpec object.

from java.lang.Objectclone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait

Methods

public DataSpec addToDataSpec(DataSpec dataSpec)

Adds Common Media Client Data (CMCD) related information to the provided DataSpec object.

Source

/*
 * Copyright 2023 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.upstream;

import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Assertions.checkState;
import static java.lang.Math.max;
import static java.lang.annotation.ElementType.TYPE_USE;

import android.net.Uri;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.annotation.StringDef;
import androidx.media3.common.C;
import androidx.media3.common.C.TrackType;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.TrackGroup;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.DataSpec;
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
import com.google.common.base.Joiner;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableMap;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.regex.Pattern;

/**
 * This class provides functionality for generating and adding Common Media Client Data (CMCD) data
 * to adaptive streaming formats, DASH, HLS, and SmoothStreaming.
 *
 * <p>It encapsulates the necessary attributes and information relevant to media content playback,
 * following the guidelines specified in the CMCD standard document <a
 * href="https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf">CTA-5004</a>.
 */
@UnstableApi
public final class CmcdData {

  /** {@link CmcdData.Factory} for {@link CmcdData} instances. */
  public static final class Factory {

    /** Represents the Dynamic Adaptive Streaming over HTTP (DASH) format. */
    public static final String STREAMING_FORMAT_DASH = "d";

    /** Represents the HTTP Live Streaming (HLS) format. */
    public static final String STREAMING_FORMAT_HLS = "h";

    /** Represents the Smooth Streaming (SS) format. */
    public static final String STREAMING_FORMAT_SS = "s";

    /** Represents the Video on Demand (VOD) stream type. */
    public static final String STREAM_TYPE_VOD = "v";

    /** Represents the Live Streaming stream type. */
    public static final String STREAM_TYPE_LIVE = "l";

    /** Represents the object type for an initialization segment in a media container. */
    public static final String OBJECT_TYPE_INIT_SEGMENT = "i";

    /** Represents the object type for audio-only content in a media container. */
    public static final String OBJECT_TYPE_AUDIO_ONLY = "a";

    /** Represents the object type for video-only content in a media container. */
    public static final String OBJECT_TYPE_VIDEO_ONLY = "v";

    /** Represents the object type for muxed audio and video content in a media container. */
    public static final String OBJECT_TYPE_MUXED_AUDIO_AND_VIDEO = "av";

    /**
     * Custom key names MUST carry a hyphenated prefix to ensure that there will not be a namespace
     * collision with future revisions to this specification. Clients SHOULD use a reverse-DNS
     * syntax when defining their own prefix.
     */
    private static final Pattern CUSTOM_KEY_NAME_PATTERN = Pattern.compile(".*-.*");

    private final CmcdConfiguration cmcdConfiguration;
    private final ExoTrackSelection trackSelection;
    private final long bufferedDurationUs;
    private final float playbackRate;
    private final @CmcdData.StreamingFormat String streamingFormat;
    private final boolean isLive;
    private final boolean didRebuffer;
    private final boolean isBufferEmpty;
    private long chunkDurationUs;
    @Nullable private @CmcdData.ObjectType String objectType;
    @Nullable private String nextObjectRequest;
    @Nullable private String nextRangeRequest;

    /**
     * Creates an instance.
     *
     * @param cmcdConfiguration The {@link CmcdConfiguration} for this chunk source.
     * @param trackSelection The {@linkplain ExoTrackSelection track selection}.
     * @param bufferedDurationUs The duration of media currently buffered from the current playback
     *     position, in microseconds.
     * @param playbackRate The playback rate indicating the current speed of playback.
     * @param streamingFormat The streaming format of the media content. Must be one of the allowed
     *     streaming formats specified by the {@link CmcdData.StreamingFormat} annotation.
     * @param isLive {@code true} if the media content is being streamed live, {@code false}
     *     otherwise.
     * @param didRebuffer {@code true} if a rebuffering event happened between the previous request
     *     and this one, {@code false} otherwise.
     * @param isBufferEmpty {@code true} if the queue of buffered chunks is empty, {@code false}
     *     otherwise.
     * @throws IllegalArgumentException If {@code bufferedDurationUs} is negative or {@code
     *     playbackRate} is non-positive.
     */
    public Factory(
        CmcdConfiguration cmcdConfiguration,
        ExoTrackSelection trackSelection,
        long bufferedDurationUs,
        float playbackRate,
        @CmcdData.StreamingFormat String streamingFormat,
        boolean isLive,
        boolean didRebuffer,
        boolean isBufferEmpty) {
      checkArgument(bufferedDurationUs >= 0);
      checkArgument(playbackRate == C.RATE_UNSET || playbackRate > 0);
      this.cmcdConfiguration = cmcdConfiguration;
      this.trackSelection = trackSelection;
      this.bufferedDurationUs = bufferedDurationUs;
      this.playbackRate = playbackRate;
      this.streamingFormat = streamingFormat;
      this.isLive = isLive;
      this.didRebuffer = didRebuffer;
      this.isBufferEmpty = isBufferEmpty;
      this.chunkDurationUs = C.TIME_UNSET;
    }

    /**
     * Retrieves the object type value from the given {@link ExoTrackSelection}.
     *
     * @param trackSelection The {@link ExoTrackSelection} from which to retrieve the object type.
     * @return The object type value as a String if {@link TrackType} can be mapped to one of the
     *     object types specified by {@link CmcdData.ObjectType} annotation, or {@code null}.
     * @throws IllegalArgumentException if the provided {@link ExoTrackSelection} is {@code null}.
     */
    @Nullable
    public static @CmcdData.ObjectType String getObjectType(ExoTrackSelection trackSelection) {
      checkArgument(trackSelection != null);
      @TrackType
      int trackType = MimeTypes.getTrackType(trackSelection.getSelectedFormat().sampleMimeType);
      if (trackType == C.TRACK_TYPE_UNKNOWN) {
        trackType = MimeTypes.getTrackType(trackSelection.getSelectedFormat().containerMimeType);
      }

      if (trackType == C.TRACK_TYPE_AUDIO) {
        return OBJECT_TYPE_AUDIO_ONLY;
      } else if (trackType == C.TRACK_TYPE_VIDEO) {
        return OBJECT_TYPE_VIDEO_ONLY;
      } else {
        // Track type cannot be mapped to a known object type.
        return null;
      }
    }

    /**
     * Sets the duration of current media chunk being requested, in microseconds. The default value
     * is {@link C#TIME_UNSET}.
     *
     * @throws IllegalArgumentException If {@code chunkDurationUs} is negative.
     */
    @CanIgnoreReturnValue
    public Factory setChunkDurationUs(long chunkDurationUs) {
      checkArgument(chunkDurationUs >= 0);
      this.chunkDurationUs = chunkDurationUs;
      return this;
    }

    /**
     * Sets the object type of the current object being requested. Must be one of the allowed object
     * types specified by the {@link CmcdData.ObjectType} annotation.
     *
     * <p>Default is {@code null}.
     */
    @CanIgnoreReturnValue
    public Factory setObjectType(@Nullable @CmcdData.ObjectType String objectType) {
      this.objectType = objectType;
      return this;
    }

    /**
     * Sets the relative path of the next object to be requested. This can be used to trigger
     * pre-fetching by the CDN.
     *
     * <p>Default is {@code null}.
     */
    @CanIgnoreReturnValue
    public Factory setNextObjectRequest(@Nullable String nextObjectRequest) {
      this.nextObjectRequest = nextObjectRequest;
      return this;
    }

    /**
     * Sets the byte range representing the partial object request. This can be used to trigger
     * pre-fetching by the CDN.
     *
     * <p>Default is {@code null}.
     */
    @CanIgnoreReturnValue
    public Factory setNextRangeRequest(@Nullable String nextRangeRequest) {
      this.nextRangeRequest = nextRangeRequest;
      return this;
    }

    public CmcdData createCmcdData() {
      ImmutableListMultimap<@CmcdConfiguration.HeaderKey String, String> customData =
          cmcdConfiguration.requestConfig.getCustomData();
      for (String headerKey : customData.keySet()) {
        validateCustomDataListFormat(customData.get(headerKey));
      }

      int bitrateKbps = Util.ceilDivide(trackSelection.getSelectedFormat().bitrate, 1000);

      CmcdObject.Builder cmcdObject = new CmcdObject.Builder();
      if (!getIsInitSegment()) {
        if (cmcdConfiguration.isBitrateLoggingAllowed()) {
          cmcdObject.setBitrateKbps(bitrateKbps);
        }
        if (cmcdConfiguration.isTopBitrateLoggingAllowed()) {
          TrackGroup trackGroup = trackSelection.getTrackGroup();
          int topBitrate = trackSelection.getSelectedFormat().bitrate;
          for (int i = 0; i < trackGroup.length; i++) {
            topBitrate = max(topBitrate, trackGroup.getFormat(i).bitrate);
          }
          cmcdObject.setTopBitrateKbps(Util.ceilDivide(topBitrate, 1000));
        }
        if (cmcdConfiguration.isObjectDurationLoggingAllowed()) {
          cmcdObject.setObjectDurationMs(Util.usToMs(chunkDurationUs));
        }
      }
      if (cmcdConfiguration.isObjectTypeLoggingAllowed()) {
        cmcdObject.setObjectType(objectType);
      }
      if (customData.containsKey(CmcdConfiguration.KEY_CMCD_OBJECT)) {
        cmcdObject.setCustomDataList(customData.get(CmcdConfiguration.KEY_CMCD_OBJECT));
      }

      CmcdRequest.Builder cmcdRequest = new CmcdRequest.Builder();
      if (!getIsInitSegment() && cmcdConfiguration.isBufferLengthLoggingAllowed()) {
        cmcdRequest.setBufferLengthMs(Util.usToMs(bufferedDurationUs));
      }
      if (cmcdConfiguration.isMeasuredThroughputLoggingAllowed()
          && trackSelection.getLatestBitrateEstimate() != C.RATE_UNSET_INT) {
        cmcdRequest.setMeasuredThroughputInKbps(
            Util.ceilDivide(trackSelection.getLatestBitrateEstimate(), 1000));
      }
      if (cmcdConfiguration.isDeadlineLoggingAllowed()) {
        cmcdRequest.setDeadlineMs(Util.usToMs((long) (bufferedDurationUs / playbackRate)));
      }
      if (cmcdConfiguration.isStartupLoggingAllowed()) {
        cmcdRequest.setStartup(didRebuffer || isBufferEmpty);
      }
      if (cmcdConfiguration.isNextObjectRequestLoggingAllowed()) {
        cmcdRequest.setNextObjectRequest(nextObjectRequest);
      }
      if (cmcdConfiguration.isNextRangeRequestLoggingAllowed()) {
        cmcdRequest.setNextRangeRequest(nextRangeRequest);
      }
      if (customData.containsKey(CmcdConfiguration.KEY_CMCD_REQUEST)) {
        cmcdRequest.setCustomDataList(customData.get(CmcdConfiguration.KEY_CMCD_REQUEST));
      }

      CmcdSession.Builder cmcdSession = new CmcdSession.Builder();
      if (cmcdConfiguration.isContentIdLoggingAllowed()) {
        cmcdSession.setContentId(cmcdConfiguration.contentId);
      }
      if (cmcdConfiguration.isSessionIdLoggingAllowed()) {
        cmcdSession.setSessionId(cmcdConfiguration.sessionId);
      }
      if (cmcdConfiguration.isStreamingFormatLoggingAllowed()) {
        cmcdSession.setStreamingFormat(streamingFormat);
      }
      if (cmcdConfiguration.isStreamTypeLoggingAllowed()) {
        cmcdSession.setStreamType(isLive ? STREAM_TYPE_LIVE : STREAM_TYPE_VOD);
      }
      if (cmcdConfiguration.isPlaybackRateLoggingAllowed()) {
        cmcdSession.setPlaybackRate(playbackRate);
      }
      if (customData.containsKey(CmcdConfiguration.KEY_CMCD_SESSION)) {
        cmcdSession.setCustomDataList(customData.get(CmcdConfiguration.KEY_CMCD_SESSION));
      }

      CmcdStatus.Builder cmcdStatus = new CmcdStatus.Builder();
      if (cmcdConfiguration.isMaximumRequestThroughputLoggingAllowed()) {
        cmcdStatus.setMaximumRequestedThroughputKbps(
            cmcdConfiguration.requestConfig.getRequestedMaximumThroughputKbps(bitrateKbps));
      }
      if (cmcdConfiguration.isBufferStarvationLoggingAllowed()) {
        cmcdStatus.setBufferStarvation(didRebuffer);
      }
      if (customData.containsKey(CmcdConfiguration.KEY_CMCD_STATUS)) {
        cmcdStatus.setCustomDataList(customData.get(CmcdConfiguration.KEY_CMCD_STATUS));
      }

      return new CmcdData(
          cmcdObject.build(),
          cmcdRequest.build(),
          cmcdSession.build(),
          cmcdStatus.build(),
          cmcdConfiguration.dataTransmissionMode);
    }

    private boolean getIsInitSegment() {
      return objectType != null && objectType.equals(OBJECT_TYPE_INIT_SEGMENT);
    }

    private void validateCustomDataListFormat(List<String> customDataList) {
      for (String customData : customDataList) {
        String key = Util.split(customData, "=")[0];
        checkState(CUSTOM_KEY_NAME_PATTERN.matcher(key).matches());
      }
    }
  }

  /** Indicates the streaming format used for media content. */
  @Retention(RetentionPolicy.SOURCE)
  @StringDef({
    Factory.STREAMING_FORMAT_DASH,
    Factory.STREAMING_FORMAT_HLS,
    Factory.STREAMING_FORMAT_SS
  })
  @Documented
  @Target(TYPE_USE)
  public @interface StreamingFormat {}

  /** Indicates the type of streaming for media content. */
  @Retention(RetentionPolicy.SOURCE)
  @StringDef({Factory.STREAM_TYPE_VOD, Factory.STREAM_TYPE_LIVE})
  @Documented
  @Target(TYPE_USE)
  public @interface StreamType {}

  /** Indicates the media type of current object being requested. */
  @Retention(RetentionPolicy.SOURCE)
  @StringDef({
    Factory.OBJECT_TYPE_INIT_SEGMENT,
    Factory.OBJECT_TYPE_AUDIO_ONLY,
    Factory.OBJECT_TYPE_VIDEO_ONLY,
    Factory.OBJECT_TYPE_MUXED_AUDIO_AND_VIDEO
  })
  @Documented
  @Target(TYPE_USE)
  public @interface ObjectType {}

  private static final Joiner COMMA_JOINER = Joiner.on(",");

  private final CmcdObject cmcdObject;
  private final CmcdRequest cmcdRequest;
  private final CmcdSession cmcdSession;
  private final CmcdStatus cmcdStatus;
  private final @CmcdConfiguration.DataTransmissionMode int dataTransmissionMode;

  private CmcdData(
      CmcdObject cmcdObject,
      CmcdRequest cmcdRequest,
      CmcdSession cmcdSession,
      CmcdStatus cmcdStatus,
      @CmcdConfiguration.DataTransmissionMode int datatTransmissionMode) {
    this.cmcdObject = cmcdObject;
    this.cmcdRequest = cmcdRequest;
    this.cmcdSession = cmcdSession;
    this.cmcdStatus = cmcdStatus;
    this.dataTransmissionMode = datatTransmissionMode;
  }

  /**
   * Adds Common Media Client Data (CMCD) related information to the provided {@link DataSpec}
   * object.
   */
  public DataSpec addToDataSpec(DataSpec dataSpec) {
    ArrayListMultimap<String, String> cmcdDataMap = ArrayListMultimap.create();
    cmcdObject.populateCmcdDataMap(cmcdDataMap);
    cmcdRequest.populateCmcdDataMap(cmcdDataMap);
    cmcdSession.populateCmcdDataMap(cmcdDataMap);
    cmcdStatus.populateCmcdDataMap(cmcdDataMap);

    if (dataTransmissionMode == CmcdConfiguration.MODE_REQUEST_HEADER) {
      ImmutableMap.Builder<String, String> httpRequestHeaders = ImmutableMap.builder();
      for (String headerKey : cmcdDataMap.keySet()) {
        List<String> headerValues = cmcdDataMap.get(headerKey);
        Collections.sort(headerValues);
        httpRequestHeaders.put(headerKey, COMMA_JOINER.join(headerValues));
      }
      return dataSpec.withAdditionalHeaders(httpRequestHeaders.buildOrThrow());
    } else {
      List<String> keyValuePairs = new ArrayList<>();
      for (Collection<String> values : cmcdDataMap.asMap().values()) {
        keyValuePairs.addAll(values);
      }
      Collections.sort(keyValuePairs);
      Uri.Builder uriBuilder =
          dataSpec
              .uri
              .buildUpon()
              .appendQueryParameter(
                  CmcdConfiguration.CMCD_QUERY_PARAMETER_KEY, COMMA_JOINER.join(keyValuePairs));
      return dataSpec.buildUpon().setUri(uriBuilder.build()).build();
    }
  }

  /**
   * Keys whose values vary with the object being requested. Contains CMCD fields: {@code br},
   * {@code tb}, {@code d} and {@code ot}.
   */
  private static final class CmcdObject {

    /** Builder for {@link CmcdObject} instances. */
    public static final class Builder {
      private int bitrateKbps;
      private int topBitrateKbps;
      private long objectDurationMs;
      @Nullable private @ObjectType String objectType;
      private ImmutableList<String> customDataList;

      /** Creates a new instance with default values. */
      public Builder() {
        this.bitrateKbps = C.RATE_UNSET_INT;
        this.topBitrateKbps = C.RATE_UNSET_INT;
        this.objectDurationMs = C.TIME_UNSET;
        this.customDataList = ImmutableList.of();
      }

      /**
       * Sets the {@link CmcdObject#bitrateKbps}. The default value is {@link C#RATE_UNSET_INT}.
       *
       * @throws IllegalArgumentException If {@code bitrateKbps} is not equal to {@link
       *     C#RATE_UNSET_INT} and is negative.
       */
      @CanIgnoreReturnValue
      public Builder setBitrateKbps(int bitrateKbps) {
        checkArgument(bitrateKbps >= 0 || bitrateKbps == C.RATE_UNSET_INT);
        this.bitrateKbps = bitrateKbps;
        return this;
      }

      /**
       * Sets the {@link CmcdObject#topBitrateKbps}. The default value is {@link C#RATE_UNSET_INT}.
       *
       * @throws IllegalArgumentException If {@code topBitrateKbps} is not equal to {@link
       *     C#RATE_UNSET_INT} and is negative.
       */
      @CanIgnoreReturnValue
      public Builder setTopBitrateKbps(int topBitrateKbps) {
        checkArgument(topBitrateKbps >= 0 || topBitrateKbps == C.RATE_UNSET_INT);
        this.topBitrateKbps = topBitrateKbps;
        return this;
      }

      /**
       * Sets the {@link CmcdObject#objectDurationMs}. The default value is {@link C#TIME_UNSET}.
       *
       * @throws IllegalArgumentException If {@code objectDurationMs} is not equal to {@link
       *     C#TIME_UNSET} and is negative.
       */
      @CanIgnoreReturnValue
      public Builder setObjectDurationMs(long objectDurationMs) {
        checkArgument(objectDurationMs >= 0 || objectDurationMs == C.TIME_UNSET);
        this.objectDurationMs = objectDurationMs;
        return this;
      }

      /** Sets the {@link CmcdObject#objectType}. The default value is {@code null}. */
      @CanIgnoreReturnValue
      public Builder setObjectType(@Nullable @ObjectType String objectType) {
        this.objectType = objectType;
        return this;
      }

      /** Sets the {@link CmcdObject#customDataList}. The default value is an empty list. */
      @CanIgnoreReturnValue
      public Builder setCustomDataList(List<String> customDataList) {
        this.customDataList = ImmutableList.copyOf(customDataList);
        return this;
      }

      public CmcdObject build() {
        return new CmcdObject(this);
      }
    }

    /**
     * The encoded bitrate in kbps of the audio or video object being requested, or {@link
     * C#RATE_UNSET_INT} if unset.
     *
     * <p>This may not be known precisely by the player; however, it MAY be estimated based upon
     * playlist/manifest declarations. If the playlist declares both peak and average bitrate
     * values, the peak value should be transmitted.
     */
    public final int bitrateKbps;

    /**
     * The highest bitrate rendition, in kbps, in the manifest or playlist that the client is
     * allowed to play, given current codec, licensing and sizing constraints. If unset, it is
     * represented by the value {@link C#RATE_UNSET_INT}.
     */
    public final int topBitrateKbps;

    /**
     * The playback duration in milliseconds of the object being requested, or {@link C#TIME_UNSET}
     * if unset. If a partial segment is being requested, then this value MUST indicate the playback
     * duration of that part and not that of its parent segment. This value can be an approximation
     * of the estimated duration if the explicit value is not known.
     */
    public final long objectDurationMs;

    /**
     * The media type of the current object being requested , or {@code null} if unset. Must be one
     * of the allowed object types specified by the {@link ObjectType} annotation.
     *
     * <p>If the object type being requested is unknown, then this key MUST NOT be used.
     */
    @Nullable public final @ObjectType String objectType;

    /** Custom data that vary based on the specific object being requested. */
    public final ImmutableList<String> customDataList;

    private CmcdObject(Builder builder) {
      this.bitrateKbps = builder.bitrateKbps;
      this.topBitrateKbps = builder.topBitrateKbps;
      this.objectDurationMs = builder.objectDurationMs;
      this.objectType = builder.objectType;
      this.customDataList = builder.customDataList;
    }

    /**
     * Populates the {@code cmcdDataMap} with {@link CmcdConfiguration#KEY_CMCD_OBJECT} values.
     *
     * @param cmcdDataMap An {@link ArrayListMultimap} to which CMCD data will be added.
     */
    public void populateCmcdDataMap(
        ArrayListMultimap<@CmcdConfiguration.HeaderKey String, String> cmcdDataMap) {
      ArrayList<String> keyValuePairs = new ArrayList<>();
      if (bitrateKbps != C.RATE_UNSET_INT) {
        keyValuePairs.add(CmcdConfiguration.KEY_BITRATE + "=" + bitrateKbps);
      }
      if (topBitrateKbps != C.RATE_UNSET_INT) {
        keyValuePairs.add(CmcdConfiguration.KEY_TOP_BITRATE + "=" + topBitrateKbps);
      }
      if (objectDurationMs != C.TIME_UNSET) {
        keyValuePairs.add(CmcdConfiguration.KEY_OBJECT_DURATION + "=" + objectDurationMs);
      }
      if (!TextUtils.isEmpty(objectType)) {
        keyValuePairs.add(CmcdConfiguration.KEY_OBJECT_TYPE + "=" + objectType);
      }
      keyValuePairs.addAll(customDataList);

      if (!keyValuePairs.isEmpty()) {
        cmcdDataMap.putAll(CmcdConfiguration.KEY_CMCD_OBJECT, keyValuePairs);
      }
    }
  }

  /**
   * Keys whose values vary with each request. Contains CMCD fields: {@code bl}, {@code mtp}, {@code
   * dl}, {@code su}, {@code nor} and {@code nrr}.
   */
  private static final class CmcdRequest {

    /** Builder for {@link CmcdRequest} instances. */
    public static final class Builder {
      private long bufferLengthMs;
      private long measuredThroughputInKbps;
      private long deadlineMs;
      private boolean startup;
      @Nullable private String nextObjectRequest;
      @Nullable private String nextRangeRequest;
      private ImmutableList<String> customDataList;

      /** Creates a new instance with default values. */
      public Builder() {
        this.bufferLengthMs = C.TIME_UNSET;
        this.measuredThroughputInKbps = C.RATE_UNSET_INT;
        this.deadlineMs = C.TIME_UNSET;
        this.customDataList = ImmutableList.of();
      }

      /**
       * Sets the {@link CmcdRequest#bufferLengthMs}. Rounded to nearest 100 ms. The default value
       * is {@link C#TIME_UNSET}.
       *
       * @throws IllegalArgumentException If {@code bufferLengthMs} is not equal to {@link
       *     C#TIME_UNSET} and is negative.
       */
      @CanIgnoreReturnValue
      public Builder setBufferLengthMs(long bufferLengthMs) {
        checkArgument(bufferLengthMs >= 0 || bufferLengthMs == C.TIME_UNSET);
        this.bufferLengthMs = ((bufferLengthMs + 50) / 100) * 100;
        return this;
      }

      /**
       * Sets the {@link CmcdRequest#measuredThroughputInKbps}. Rounded to nearest 100 kbps. The
       * default value is {@link C#RATE_UNSET_INT}.
       *
       * @throws IllegalArgumentException If {@code measuredThroughputInKbps} is not equal to {@link
       *     C#RATE_UNSET_INT} and is negative.
       */
      @CanIgnoreReturnValue
      public Builder setMeasuredThroughputInKbps(long measuredThroughputInKbps) {
        checkArgument(
            measuredThroughputInKbps >= 0 || measuredThroughputInKbps == C.RATE_UNSET_INT);
        this.measuredThroughputInKbps = ((measuredThroughputInKbps + 50) / 100) * 100;

        return this;
      }

      /**
       * Sets the {@link CmcdRequest#deadlineMs}. Rounded to nearest 100 ms. The default value is
       * {@link C#TIME_UNSET}.
       *
       * @throws IllegalArgumentException If {@code deadlineMs} is not equal to {@link C#TIME_UNSET}
       *     and is negative.
       */
      @CanIgnoreReturnValue
      public Builder setDeadlineMs(long deadlineMs) {
        checkArgument(deadlineMs >= 0 || deadlineMs == C.TIME_UNSET);
        this.deadlineMs = ((deadlineMs + 50) / 100) * 100;
        return this;
      }

      /** Sets the {@link CmcdRequest#startup}. The default value is {@code false}. */
      @CanIgnoreReturnValue
      public Builder setStartup(boolean startup) {
        this.startup = startup;
        return this;
      }

      /**
       * Sets the {@link CmcdRequest#nextObjectRequest}. This string is URL encoded. The default
       * value is {@code null}.
       */
      @CanIgnoreReturnValue
      public Builder setNextObjectRequest(@Nullable String nextObjectRequest) {
        this.nextObjectRequest = nextObjectRequest == null ? null : Uri.encode(nextObjectRequest);
        return this;
      }

      /** Sets the {@link CmcdRequest#nextRangeRequest}. The default value is {@code null}. */
      @CanIgnoreReturnValue
      public Builder setNextRangeRequest(@Nullable String nextRangeRequest) {
        this.nextRangeRequest = nextRangeRequest;
        return this;
      }

      /** Sets the {@link CmcdRequest#customDataList}. The default value is an empty list. */
      @CanIgnoreReturnValue
      public Builder setCustomDataList(List<String> customDataList) {
        this.customDataList = ImmutableList.copyOf(customDataList);
        return this;
      }

      public CmcdRequest build() {
        return new CmcdRequest(this);
      }
    }

    /**
     * The buffer length in milliseconds associated with the media object being requested, or {@link
     * C#TIME_UNSET} if unset.
     *
     * <p>This key SHOULD only be sent with an {@link CmcdObject#objectType} of {@link
     * Factory#OBJECT_TYPE_AUDIO_ONLY}, {@link Factory#OBJECT_TYPE_VIDEO_ONLY} or {@link
     * Factory#OBJECT_TYPE_MUXED_AUDIO_AND_VIDEO}.
     *
     * <p>This value MUST be rounded to the nearest 100 ms.
     */
    public final long bufferLengthMs;

    /**
     * The throughput between client and server, as measured by the client, or {@link
     * C#RATE_UNSET_INT} if unset.
     *
     * <p>This value MUST be rounded to the nearest 100 kbps. This value, however derived, SHOULD be
     * the value that the client is using to make its next Adaptive Bitrate switching decision. If
     * the client is connected to multiple servers concurrently, it must take care to report only
     * the throughput measured against the receiving server. If the client has multiple concurrent
     * connections to the server, then the intent is that this value communicates the aggregate
     * throughput the client sees across all those connections.
     */
    public final long measuredThroughputInKbps;

    /**
     * Deadline in milliseconds from the request time until the first sample of this Segment/Object
     * needs to be available in order to not create a buffer underrun or any other playback
     * problems, or {@link C#TIME_UNSET} if unset.
     *
     * <p>This value MUST be rounded to the nearest 100 ms. For a playback rate of 1, this may be
     * equivalent to the player’s remaining buffer length.
     */
    public final long deadlineMs;

    /**
     * A boolean indicating whether the chunk is needed urgently due to startup, seeking or recovery
     * after a buffer-empty event, or {@code false} if unknown. The media SHOULD not be rendering
     * when this request is made.
     */
    public final boolean startup;

    /**
     * Relative path of the next object to be requested, or {@code null} if unset. This can be used
     * to trigger pre-fetching by the CDN. This MUST be a path relative to the current request.
     *
     * <p>This string MUST be URL encoded.
     *
     * <p><b>Note:</b> The client SHOULD NOT depend upon any pre-fetch action being taken - it is
     * merely a request for such a pre-fetch to take place.
     */
    @Nullable public final String nextObjectRequest;

    /**
     * The byte range representing the partial object request, or {@code null} if unset. If the
     * {@link #nextObjectRequest} field is not set, then the object is assumed to match the object
     * currently being requested.
     *
     * <p><b>Note:</b> The client SHOULD NOT depend upon any pre-fetch action being taken - it is
     * merely a request for such a pre-fetch to take place.
     */
    @Nullable public final String nextRangeRequest;

    /** Custom data that vary with each request. */
    public final ImmutableList<String> customDataList;

    private CmcdRequest(Builder builder) {
      this.bufferLengthMs = builder.bufferLengthMs;
      this.measuredThroughputInKbps = builder.measuredThroughputInKbps;
      this.deadlineMs = builder.deadlineMs;
      this.startup = builder.startup;
      this.nextObjectRequest = builder.nextObjectRequest;
      this.nextRangeRequest = builder.nextRangeRequest;
      this.customDataList = builder.customDataList;
    }

    /**
     * Populates the {@code cmcdDataMap} with {@link CmcdConfiguration#KEY_CMCD_REQUEST} values.
     *
     * @param cmcdDataMap An {@link ArrayListMultimap} to which CMCD data will be added.
     */
    public void populateCmcdDataMap(
        ArrayListMultimap<@CmcdConfiguration.HeaderKey String, String> cmcdDataMap) {
      ArrayList<String> keyValuePairs = new ArrayList<>();
      if (bufferLengthMs != C.TIME_UNSET) {
        keyValuePairs.add(CmcdConfiguration.KEY_BUFFER_LENGTH + "=" + bufferLengthMs);
      }
      if (measuredThroughputInKbps != C.RATE_UNSET_INT) {
        keyValuePairs.add(
            CmcdConfiguration.KEY_MEASURED_THROUGHPUT + "=" + measuredThroughputInKbps);
      }
      if (deadlineMs != C.TIME_UNSET) {
        keyValuePairs.add(CmcdConfiguration.KEY_DEADLINE + "=" + deadlineMs);
      }
      if (startup) {
        keyValuePairs.add(CmcdConfiguration.KEY_STARTUP);
      }
      if (!TextUtils.isEmpty(nextObjectRequest)) {
        keyValuePairs.add(
            Util.formatInvariant(
                "%s=\"%s\"", CmcdConfiguration.KEY_NEXT_OBJECT_REQUEST, nextObjectRequest));
      }
      if (!TextUtils.isEmpty(nextRangeRequest)) {
        keyValuePairs.add(
            Util.formatInvariant(
                "%s=\"%s\"", CmcdConfiguration.KEY_NEXT_RANGE_REQUEST, nextRangeRequest));
      }
      keyValuePairs.addAll(customDataList);

      if (!keyValuePairs.isEmpty()) {
        cmcdDataMap.putAll(CmcdConfiguration.KEY_CMCD_REQUEST, keyValuePairs);
      }
    }
  }

  /**
   * Keys whose values are expected to be invariant over the life of the session. Contains CMCD
   * fields: {@code cid}, {@code sid}, {@code sf}, {@code st}, {@code pr} and {@code v}.
   */
  private static final class CmcdSession {

    /** Builder for {@link CmcdSession} instances. */
    public static final class Builder {
      @Nullable private String contentId;
      @Nullable private String sessionId;
      @Nullable private @StreamingFormat String streamingFormat;
      @Nullable private @StreamType String streamType;
      private float playbackRate;
      private ImmutableList<String> customDataList;

      /** Creates a new instance with default values. */
      public Builder() {
        this.customDataList = ImmutableList.of();
      }

      /**
       * Sets the {@link CmcdSession#contentId}. Maximum length allowed is 64 characters. The
       * default value is {@code null}.
       *
       * @throws IllegalArgumentException If {@code contentId} is null or its length exceeds {@link
       *     CmcdConfiguration#MAX_ID_LENGTH}.
       */
      @CanIgnoreReturnValue
      public Builder setContentId(@Nullable String contentId) {
        checkArgument(contentId == null || contentId.length() <= CmcdConfiguration.MAX_ID_LENGTH);
        this.contentId = contentId;
        return this;
      }

      /**
       * Sets the {@link CmcdSession#sessionId}. Maximum length allowed is 64 characters. The
       * default value is {@code null}.
       *
       * @throws IllegalArgumentException If {@code sessionId} is null or its length exceeds {@link
       *     CmcdConfiguration#MAX_ID_LENGTH}.
       */
      @CanIgnoreReturnValue
      public Builder setSessionId(@Nullable String sessionId) {
        checkArgument(sessionId == null || sessionId.length() <= CmcdConfiguration.MAX_ID_LENGTH);
        this.sessionId = sessionId;
        return this;
      }

      /** Sets the {@link CmcdSession#streamingFormat}. The default value is {@code null}. */
      @CanIgnoreReturnValue
      public Builder setStreamingFormat(@Nullable @StreamingFormat String streamingFormat) {
        this.streamingFormat = streamingFormat;
        return this;
      }

      /** Sets the {@link CmcdSession#streamType}. The default value is {@code null}. */
      @CanIgnoreReturnValue
      public Builder setStreamType(@Nullable @StreamType String streamType) {
        this.streamType = streamType;
        return this;
      }

      /**
       * Sets the {@link CmcdSession#playbackRate}. The default value is {@link C#RATE_UNSET}.
       *
       * @throws IllegalArgumentException If {@code playbackRate} is not equal to {@link
       *     C#RATE_UNSET} and is non-positive.
       */
      @CanIgnoreReturnValue
      public Builder setPlaybackRate(float playbackRate) {
        checkArgument(playbackRate > 0 || playbackRate == C.RATE_UNSET);
        this.playbackRate = playbackRate;
        return this;
      }

      /** Sets the {@link CmcdSession#customDataList}. The default value is an empty list. */
      @CanIgnoreReturnValue
      public Builder setCustomDataList(List<String> customDataList) {
        this.customDataList = ImmutableList.copyOf(customDataList);
        return this;
      }

      public CmcdSession build() {
        return new CmcdSession(this);
      }
    }

    /**
     * The version of this specification used for interpreting the defined key names and values. If
     * this key is omitted, the client and server MUST interpret the values as being defined by
     * version 1. Client SHOULD omit this field if the version is 1.
     */
    public static final int VERSION = 1;

    /**
     * A GUID identifying the current content, or {@code null} if unset.
     *
     * <p>This value is consistent across multiple different sessions and devices and is defined and
     * updated at the discretion of the service provider. Maximum length is 64 characters.
     */
    @Nullable public final String contentId;

    /**
     * A GUID identifying the current playback session, or {@code null} if unset.
     *
     * <p>A playback session typically ties together segments belonging to a single media asset.
     * Maximum length is 64 characters.
     */
    @Nullable public final String sessionId;

    /**
     * The streaming format that defines the current request, or{@code null} if unset. Must be one
     * of the allowed stream formats specified by the {@link StreamingFormat} annotation. If the
     * streaming format being requested is unknown, then this key MUST NOT be used.
     */
    @Nullable public final @StreamingFormat String streamingFormat;

    /**
     * Type of stream, or {@code null} if unset. Must be one of the allowed stream types specified
     * by the {@link StreamType} annotation.
     */
    @Nullable public final @StreamType String streamType;

    /**
     * The playback rate indicating the current rate of playback, or {@link C#RATE_UNSET} if unset.
     */
    public final float playbackRate;

    /** Custom data that is expected to be invariant over the life of the session. */
    public final ImmutableList<String> customDataList;

    private CmcdSession(Builder builder) {
      this.contentId = builder.contentId;
      this.sessionId = builder.sessionId;
      this.streamingFormat = builder.streamingFormat;
      this.streamType = builder.streamType;
      this.playbackRate = builder.playbackRate;
      this.customDataList = builder.customDataList;
    }

    /**
     * Populates the {@code cmcdDataMap} with {@link CmcdConfiguration#KEY_CMCD_SESSION} values.
     *
     * @param cmcdDataMap An {@link ArrayListMultimap} to which CMCD data will be added.
     */
    public void populateCmcdDataMap(
        ArrayListMultimap<@CmcdConfiguration.HeaderKey String, String> cmcdDataMap) {
      ArrayList<String> keyValuePairs = new ArrayList<>();
      if (!TextUtils.isEmpty(this.contentId)) {
        keyValuePairs.add(
            Util.formatInvariant("%s=\"%s\"", CmcdConfiguration.KEY_CONTENT_ID, contentId));
      }
      if (!TextUtils.isEmpty(this.sessionId)) {
        keyValuePairs.add(
            Util.formatInvariant("%s=\"%s\"", CmcdConfiguration.KEY_SESSION_ID, sessionId));
      }
      if (!TextUtils.isEmpty(this.streamingFormat)) {
        keyValuePairs.add(CmcdConfiguration.KEY_STREAMING_FORMAT + "=" + streamingFormat);
      }
      if (!TextUtils.isEmpty(this.streamType)) {
        keyValuePairs.add(CmcdConfiguration.KEY_STREAM_TYPE + "=" + streamType);
      }
      if (playbackRate != C.RATE_UNSET && playbackRate != 1.0f) {
        keyValuePairs.add(
            Util.formatInvariant("%s=%.2f", CmcdConfiguration.KEY_PLAYBACK_RATE, playbackRate));
      }
      if (VERSION != 1) {
        keyValuePairs.add(CmcdConfiguration.KEY_VERSION + "=" + VERSION);
      }
      keyValuePairs.addAll(customDataList);

      if (!keyValuePairs.isEmpty()) {
        cmcdDataMap.putAll(CmcdConfiguration.KEY_CMCD_SESSION, keyValuePairs);
      }
    }
  }

  /**
   * Keys whose values do not vary with every request or object. Contains CMCD fields: {@code rtp}
   * and {@code bs}.
   */
  private static final class CmcdStatus {

    /** Builder for {@link CmcdStatus} instances. */
    public static final class Builder {
      private int maximumRequestedThroughputKbps;
      private boolean bufferStarvation;
      private ImmutableList<String> customDataList;

      /** Creates a new instance with default values. */
      public Builder() {
        this.maximumRequestedThroughputKbps = C.RATE_UNSET_INT;
        this.customDataList = ImmutableList.of();
      }

      /**
       * Sets the {@link CmcdStatus#maximumRequestedThroughputKbps}. Rounded to nearest 100 kbps.
       * The default value is {@link C#RATE_UNSET_INT}.
       *
       * @throws IllegalArgumentException If {@code maximumRequestedThroughputKbps} is not equal to
       *     {@link C#RATE_UNSET_INT} and is negative.
       */
      @CanIgnoreReturnValue
      public Builder setMaximumRequestedThroughputKbps(int maximumRequestedThroughputKbps) {
        checkArgument(
            maximumRequestedThroughputKbps >= 0
                || maximumRequestedThroughputKbps == C.RATE_UNSET_INT);

        this.maximumRequestedThroughputKbps =
            maximumRequestedThroughputKbps == C.RATE_UNSET_INT
                ? maximumRequestedThroughputKbps
                : ((maximumRequestedThroughputKbps + 50) / 100) * 100;

        return this;
      }

      /** Sets the {@link CmcdStatus#bufferStarvation}. The default value is {@code false}. */
      @CanIgnoreReturnValue
      public Builder setBufferStarvation(boolean bufferStarvation) {
        this.bufferStarvation = bufferStarvation;
        return this;
      }

      /** Sets the {@link CmcdStatus#customDataList}. The default value is an empty list. */
      @CanIgnoreReturnValue
      public Builder setCustomDataList(List<String> customDataList) {
        this.customDataList = ImmutableList.copyOf(customDataList);
        return this;
      }

      public CmcdStatus build() {
        return new CmcdStatus(this);
      }
    }

    /**
     * The requested maximum throughput in kbps that the client considers sufficient for delivery of
     * the asset, or {@link C#RATE_UNSET_INT} if unset. Values MUST be rounded to the nearest
     * 100kbps.
     */
    public final int maximumRequestedThroughputKbps;

    /**
     * A boolean indicating whether the buffer was starved at some point between the prior request
     * and this chunk request, resulting in the player being in a rebuffering state and the video or
     * audio playback being stalled, or {@code false} if unknown.
     */
    public final boolean bufferStarvation;

    /** Custom data that do not vary with every request or object. */
    public final ImmutableList<String> customDataList;

    private CmcdStatus(Builder builder) {
      this.maximumRequestedThroughputKbps = builder.maximumRequestedThroughputKbps;
      this.bufferStarvation = builder.bufferStarvation;
      this.customDataList = builder.customDataList;
    }

    /**
     * Populates the {@code cmcdDataMap} with {@link CmcdConfiguration#KEY_CMCD_STATUS} values.
     *
     * @param cmcdDataMap An {@link ArrayListMultimap} to which CMCD data will be added.
     */
    public void populateCmcdDataMap(
        ArrayListMultimap<@CmcdConfiguration.HeaderKey String, String> cmcdDataMap) {
      ArrayList<String> keyValuePairs = new ArrayList<>();
      if (maximumRequestedThroughputKbps != C.RATE_UNSET_INT) {
        keyValuePairs.add(
            CmcdConfiguration.KEY_MAXIMUM_REQUESTED_BITRATE + "=" + maximumRequestedThroughputKbps);
      }
      if (bufferStarvation) {
        keyValuePairs.add(CmcdConfiguration.KEY_BUFFER_STARVATION);
      }
      keyValuePairs.addAll(customDataList);

      if (!keyValuePairs.isEmpty()) {
        cmcdDataMap.putAll(CmcdConfiguration.KEY_CMCD_STATUS, keyValuePairs);
      }
    }
  }
}