CmcdHeadersFactory.java

/*
 * 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 java.lang.Math.max;
import static java.lang.annotation.ElementType.TYPE_USE;

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.exoplayer.trackselection.ExoTrackSelection;
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;

/**
 * This class serves as a factory for generating Common Media Client Data (CMCD) HTTP request
 * headers in 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 CmcdHeadersFactory {

  /**
   * 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 ObjectType} annotation, or {@code null}.
   * @throws IllegalArgumentException if the provided {@link ExoTrackSelection} is {@code null}.
   */
  @Nullable
  public static @ObjectType String getObjectType(ExoTrackSelection trackSelection) {
    checkArgument(trackSelection != null);
    @C.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;
    }
  }

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

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

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

  /** 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";

  private final CmcdConfiguration cmcdConfiguration;
  private final ExoTrackSelection trackSelection;
  private final long bufferedDurationUs;
  private final @StreamingFormat String streamingFormat;
  private final boolean isLive;
  private long chunkDurationUs;
  private @Nullable @ObjectType String objectType;

  /**
   * 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 streamingFormat The streaming format of the media content. Must be one of the allowed
   *     streaming formats specified by the {@link StreamingFormat} annotation.
   * @param isLive {@code true} if the media content is being streamed live, {@code false}
   *     otherwise.
   * @throws IllegalArgumentException If {@code bufferedDurationUs} is negative.
   */
  public CmcdHeadersFactory(
      CmcdConfiguration cmcdConfiguration,
      ExoTrackSelection trackSelection,
      long bufferedDurationUs,
      @StreamingFormat String streamingFormat,
      boolean isLive) {
    checkArgument(bufferedDurationUs >= 0);
    this.cmcdConfiguration = cmcdConfiguration;
    this.trackSelection = trackSelection;
    this.bufferedDurationUs = bufferedDurationUs;
    this.streamingFormat = streamingFormat;
    this.isLive = isLive;
    this.chunkDurationUs = C.TIME_UNSET;
  }

  /**
   * 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 CmcdHeadersFactory 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 ObjectType} annotation.
   *
   * <p>Default is {@code null}.
   */
  @CanIgnoreReturnValue
  public CmcdHeadersFactory setObjectType(@Nullable @ObjectType String objectType) {
    this.objectType = objectType;
    return this;
  }

  /** Creates and returns a new {@link ImmutableMap} containing the CMCD HTTP request headers. */
  public ImmutableMap<@CmcdConfiguration.HeaderKey String, String> createHttpRequestHeaders() {
    ImmutableMap<@CmcdConfiguration.HeaderKey String, String> customData =
        cmcdConfiguration.requestConfig.getCustomData();
    int bitrateKbps = Util.ceilDivide(trackSelection.getSelectedFormat().bitrate, 1000);

    CmcdObject.Builder cmcdObject =
        new CmcdObject.Builder().setCustomData(customData.get(CmcdConfiguration.KEY_CMCD_OBJECT));
    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() && chunkDurationUs != C.TIME_UNSET) {
        cmcdObject.setObjectDurationMs(chunkDurationUs / 1000);
      }
    }

    if (cmcdConfiguration.isObjectTypeLoggingAllowed()) {
      cmcdObject.setObjectType(objectType);
    }

    CmcdRequest.Builder cmcdRequest =
        new CmcdRequest.Builder().setCustomData(customData.get(CmcdConfiguration.KEY_CMCD_REQUEST));
    if (!getIsInitSegment() && cmcdConfiguration.isBufferLengthLoggingAllowed()) {
      cmcdRequest.setBufferLengthMs(bufferedDurationUs / 1000);
    }
    if (cmcdConfiguration.isMeasuredThroughputLoggingAllowed()
        && trackSelection.getLatestBitrateEstimate() != Long.MIN_VALUE) {
      cmcdRequest.setMeasuredThroughputInKbps(
          Util.ceilDivide(trackSelection.getLatestBitrateEstimate(), 1000));
    }

    CmcdSession.Builder cmcdSession =
        new CmcdSession.Builder().setCustomData(customData.get(CmcdConfiguration.KEY_CMCD_SESSION));
    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);
    }

    CmcdStatus.Builder cmcdStatus =
        new CmcdStatus.Builder().setCustomData(customData.get(CmcdConfiguration.KEY_CMCD_STATUS));
    if (cmcdConfiguration.isMaximumRequestThroughputLoggingAllowed()) {
      cmcdStatus.setMaximumRequestedThroughputKbps(
          cmcdConfiguration.requestConfig.getRequestedMaximumThroughputKbps(bitrateKbps));
    }

    ImmutableMap.Builder<String, String> httpRequestHeaders = ImmutableMap.builder();
    cmcdObject.build().populateHttpRequestHeaders(httpRequestHeaders);
    cmcdRequest.build().populateHttpRequestHeaders(httpRequestHeaders);
    cmcdSession.build().populateHttpRequestHeaders(httpRequestHeaders);
    cmcdStatus.build().populateHttpRequestHeaders(httpRequestHeaders);
    return httpRequestHeaders.buildOrThrow();
  }

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

  /** Keys whose values vary with the object being requested. Contains CMCD fields: {@code br}. */
  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;
      @Nullable private String customData;

      /** 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;
      }

      /** Sets the {@link CmcdObject#bitrateKbps}. The default value is {@link C#RATE_UNSET_INT}. */
      @CanIgnoreReturnValue
      public Builder setBitrateKbps(int bitrateKbps) {
        this.bitrateKbps = bitrateKbps;
        return this;
      }

      /**
       * Sets the {@link CmcdObject#topBitrateKbps}. The default value is {@link C#RATE_UNSET_INT}.
       */
      @CanIgnoreReturnValue
      public Builder setTopBitrateKbps(int topBitrateKbps) {
        this.topBitrateKbps = topBitrateKbps;
        return this;
      }

      /**
       * Sets the {@link CmcdObject#objectDurationMs}. The default value is {@link C#TIME_UNSET}.
       *
       * @throws IllegalArgumentException If {@code objectDurationMs} is negative.
       */
      @CanIgnoreReturnValue
      public Builder setObjectDurationMs(long objectDurationMs) {
        checkArgument(objectDurationMs >= 0);
        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#customData}. The default value is {@code null}. */
      @CanIgnoreReturnValue
      public Builder setCustomData(@Nullable String customData) {
        this.customData = customData;
        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 where the values of the keys vary with the object being requested, or {@code
     * null} if unset.
     *
     * <p>The String consists of key-value pairs separated by commas.<br>
     * Example: {@code key1=intValue,key2="stringValue"}.
     */
    @Nullable public final String customData;

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

    /**
     * Populates the HTTP request headers with {@link CmcdConfiguration#KEY_CMCD_OBJECT} values.
     *
     * @param httpRequestHeaders An {@link ImmutableMap.Builder} used to build the HTTP request
     *     headers.
     */
    public void populateHttpRequestHeaders(
        ImmutableMap.Builder<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders) {
      StringBuilder headerValue = new StringBuilder();
      if (bitrateKbps != C.RATE_UNSET_INT) {
        headerValue.append(
            Util.formatInvariant("%s=%d,", CmcdConfiguration.KEY_BITRATE, bitrateKbps));
      }
      if (topBitrateKbps != C.RATE_UNSET_INT) {
        headerValue.append(
            Util.formatInvariant("%s=%d,", CmcdConfiguration.KEY_TOP_BITRATE, topBitrateKbps));
      }
      if (objectDurationMs != C.TIME_UNSET) {
        headerValue.append(
            Util.formatInvariant(
                "%s=%d,", CmcdConfiguration.KEY_OBJECT_DURATION, objectDurationMs));
      }
      if (!TextUtils.isEmpty(objectType)) {
        headerValue.append(
            Util.formatInvariant("%s=%s,", CmcdConfiguration.KEY_OBJECT_TYPE, objectType));
      }
      if (!TextUtils.isEmpty(customData)) {
        headerValue.append(Util.formatInvariant("%s,", customData));
      }

      if (headerValue.length() == 0) {
        return;
      }
      // Remove the trailing comma as headerValue is not empty
      headerValue.setLength(headerValue.length() - 1);
      httpRequestHeaders.put(CmcdConfiguration.KEY_CMCD_OBJECT, headerValue.toString());
    }
  }

  /** Keys whose values vary with each request. Contains CMCD fields: {@code bl}. */
  private static final class CmcdRequest {

    /** Builder for {@link CmcdRequest} instances. */
    public static final class Builder {
      private long bufferLengthMs;
      private long measuredThroughputInKbps;
      @Nullable private String customData;

      /** Creates a new instance with default values. */
      public Builder() {
        this.bufferLengthMs = C.TIME_UNSET;
        this.measuredThroughputInKbps = Long.MIN_VALUE;
      }

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

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

        return this;
      }

      /** Sets the {@link CmcdRequest#customData}. The default value is {@code null}. */
      @CanIgnoreReturnValue
      public Builder setCustomData(@Nullable String customData) {
        this.customData = customData;
        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
     * #OBJECT_TYPE_AUDIO_ONLY}, {@link #OBJECT_TYPE_VIDEO_ONLY} or {@link
     * #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
     * Long#MIN_VALUE} 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;

    /**
     * Custom data where the values of the keys vary with each request, or {@code null} if unset.
     *
     * <p>The String consists of key-value pairs separated by commas.<br>
     * Example: {@code key1=intValue, key2="stringValue"}.
     */
    @Nullable public final String customData;

    private CmcdRequest(Builder builder) {
      this.bufferLengthMs = builder.bufferLengthMs;
      this.measuredThroughputInKbps = builder.measuredThroughputInKbps;
      this.customData = builder.customData;
    }

    /**
     * Populates the HTTP request headers with {@link CmcdConfiguration#KEY_CMCD_REQUEST} values.
     *
     * @param httpRequestHeaders An {@link ImmutableMap.Builder} used to build the HTTP request
     *     headers.
     */
    public void populateHttpRequestHeaders(
        ImmutableMap.Builder<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders) {
      StringBuilder headerValue = new StringBuilder();
      if (bufferLengthMs != C.TIME_UNSET) {
        headerValue.append(
            Util.formatInvariant("%s=%d,", CmcdConfiguration.KEY_BUFFER_LENGTH, bufferLengthMs));
      }
      if (measuredThroughputInKbps != Long.MIN_VALUE) {
        headerValue.append(
            Util.formatInvariant(
                "%s=%d,", CmcdConfiguration.KEY_MEASURED_THROUGHPUT, measuredThroughputInKbps));
      }
      if (!TextUtils.isEmpty(customData)) {
        headerValue.append(Util.formatInvariant("%s,", customData));
      }

      if (headerValue.length() == 0) {
        return;
      }
      // Remove the trailing comma as headerValue is not empty
      headerValue.setLength(headerValue.length() - 1);
      httpRequestHeaders.put(CmcdConfiguration.KEY_CMCD_REQUEST, headerValue.toString());
    }
  }

  /**
   * Keys whose values are expected to be invariant over the life of the session. Contains CMCD
   * fields: {@code cid} and {@code sid}.
   */
  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;
      @Nullable private String customData;

      /**
       * 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#customData}. The default value is {@code null}. */
      @CanIgnoreReturnValue
      public Builder setCustomData(@Nullable String customData) {
        this.customData = customData;
        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 streaming formats specified by the {@link StreamingFormat} annotation.
     *
     * <p>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;

    /**
     * Custom data where the values of the keys are expected to be invariant over the life of the
     * session, or {@code null} if unset.
     *
     * <p>The String consists of key-value pairs separated by commas.<br>
     * Example: {@code key1=intValue, key2="stringValue"}.
     */
    @Nullable public final String customData;

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

    /**
     * Populates the HTTP request headers with {@link CmcdConfiguration#KEY_CMCD_SESSION} values.
     *
     * @param httpRequestHeaders An {@link ImmutableMap.Builder} used to build the HTTP request
     *     headers.
     */
    public void populateHttpRequestHeaders(
        ImmutableMap.Builder<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders) {
      StringBuilder headerValue = new StringBuilder();
      if (!TextUtils.isEmpty(this.contentId)) {
        headerValue.append(
            Util.formatInvariant("%s=\"%s\",", CmcdConfiguration.KEY_CONTENT_ID, contentId));
      }
      if (!TextUtils.isEmpty(this.sessionId)) {
        headerValue.append(
            Util.formatInvariant("%s=\"%s\",", CmcdConfiguration.KEY_SESSION_ID, sessionId));
      }
      if (!TextUtils.isEmpty(this.streamingFormat)) {
        headerValue.append(
            Util.formatInvariant(
                "%s=%s,", CmcdConfiguration.KEY_STREAMING_FORMAT, streamingFormat));
      }
      if (!TextUtils.isEmpty(this.streamType)) {
        headerValue.append(
            Util.formatInvariant("%s=%s,", CmcdConfiguration.KEY_STREAM_TYPE, streamType));
      }
      if (VERSION != 1) {
        headerValue.append(Util.formatInvariant("%s=%d,", CmcdConfiguration.KEY_VERSION, VERSION));
      }
      if (!TextUtils.isEmpty(customData)) {
        headerValue.append(Util.formatInvariant("%s,", customData));
      }

      if (headerValue.length() == 0) {
        return;
      }
      // Remove the trailing comma as headerValue is not empty
      headerValue.setLength(headerValue.length() - 1);
      httpRequestHeaders.put(CmcdConfiguration.KEY_CMCD_SESSION, headerValue.toString());
    }
  }

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

    /** Builder for {@link CmcdStatus} instances. */
    public static final class Builder {
      private int maximumRequestedThroughputKbps;
      @Nullable private String customData;

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

      /**
       * 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 == C.RATE_UNSET_INT
                || maximumRequestedThroughputKbps >= 0);

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

        return this;
      }

      /** Sets the {@link CmcdStatus#customData}. The default value is {@code null}. */
      @CanIgnoreReturnValue
      public Builder setCustomData(@Nullable String customData) {
        this.customData = customData;
        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;
    /**
     * Custom data where the values of the keys do not vary with every request or object, or {@code
     * null} if unset.
     *
     * <p>The String consists of key-value pairs separated by commas.<br>
     * Example: {@code key1=intValue, key2="stringValue"}.
     */
    @Nullable public final String customData;

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

    /**
     * Populates the HTTP request headers with {@link CmcdConfiguration#KEY_CMCD_STATUS} values.
     *
     * @param httpRequestHeaders An {@link ImmutableMap.Builder} used to build the HTTP request
     *     headers.
     */
    public void populateHttpRequestHeaders(
        ImmutableMap.Builder<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders) {
      StringBuilder headerValue = new StringBuilder();
      if (maximumRequestedThroughputKbps != C.RATE_UNSET_INT) {
        headerValue.append(
            Util.formatInvariant(
                "%s=%d,",
                CmcdConfiguration.KEY_MAXIMUM_REQUESTED_BITRATE, maximumRequestedThroughputKbps));
      }
      if (!TextUtils.isEmpty(customData)) {
        headerValue.append(Util.formatInvariant("%s,", customData));
      }

      if (headerValue.length() == 0) {
        return;
      }
      // Remove the trailing comma as headerValue is not empty
      headerValue.setLength(headerValue.length() - 1);
      httpRequestHeaders.put(CmcdConfiguration.KEY_CMCD_STATUS, headerValue.toString());
    }
  }
}