MediaDescription.java

/*
 * 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.rtsp;

import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Util.castNonNull;
import static androidx.media3.exoplayer.rtsp.RtspMessageUtil.parseInt;
import static androidx.media3.exoplayer.rtsp.SessionDescription.ATTR_FMTP;
import static androidx.media3.exoplayer.rtsp.SessionDescription.ATTR_RTPMAP;

import androidx.annotation.Nullable;
import androidx.annotation.StringDef;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import com.google.common.collect.ImmutableMap;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.HashMap;

/** Represents one media description section in a SDP message. */
@UnstableApi
/* package */ final class MediaDescription {

  /** Represents the mandatory RTPMAP attribute in MediaDescription. Reference RFC 2327 Page 22. */
  public static final class RtpMapAttribute {

    /** Parses the RTPMAP attribute value (with the part "a=rtpmap:" removed). */
    public static RtpMapAttribute parse(String rtpmapString) throws ParserException {
      String[] rtpmapInfo = Util.splitAtFirst(rtpmapString, " ");
      checkArgument(rtpmapInfo.length == 2);
      int payloadType = parseInt(rtpmapInfo[0]);

      String[] mediaInfo = Util.split(rtpmapInfo[1].trim(), "/");
      checkArgument(mediaInfo.length >= 2);
      int clockRate = parseInt(mediaInfo[1]);
      int encodingParameters = C.INDEX_UNSET;
      if (mediaInfo.length == 3) {
        encodingParameters = parseInt(mediaInfo[2]);
      }
      return new RtpMapAttribute(
          payloadType, /* mediaEncoding= */ mediaInfo[0], clockRate, encodingParameters);
    }

    /** The assigned RTP payload type. */
    public final int payloadType;
    /** The encoding method used in the RTP stream. */
    public final String mediaEncoding;
    /** The clock rate used in the RTP stream. */
    public final int clockRate;
    /** The optional encoding parameter. */
    public final int encodingParameters;

    private RtpMapAttribute(
        int payloadType, String mediaEncoding, int clockRate, int encodingParameters) {
      this.payloadType = payloadType;
      this.mediaEncoding = mediaEncoding;
      this.clockRate = clockRate;
      this.encodingParameters = encodingParameters;
    }

    @Override
    public boolean equals(@Nullable Object o) {
      if (this == o) {
        return true;
      }
      if (o == null || getClass() != o.getClass()) {
        return false;
      }
      RtpMapAttribute that = (RtpMapAttribute) o;
      return payloadType == that.payloadType
          && mediaEncoding.equals(that.mediaEncoding)
          && clockRate == that.clockRate
          && encodingParameters == that.encodingParameters;
    }

    @Override
    public int hashCode() {
      int result = 7;
      result = 31 * result + payloadType;
      result = 31 * result + mediaEncoding.hashCode();
      result = 31 * result + clockRate;
      result = 31 * result + encodingParameters;
      return result;
    }
  }

  /** Builder class for {@link MediaDescription}. */
  public static final class Builder {

    /**
     * RTPMAP attribute format: {@code <payloadType> <mediaEncoding>/<clockRate>/<channelCount>}.
     */
    private static final String RTP_MAP_ATTR_AUDIO_FMT = "%d %s/%d/%d";

    private static final int RTP_STATIC_PAYLOAD_TYPE_PCMU = 0;
    private static final int RTP_STATIC_PAYLOAD_TYPE_PCMA = 8;
    private static final int RTP_STATIC_PAYLOAD_TYPE_L16_STEREO = 10;
    private static final int RTP_STATIC_PAYLOAD_TYPE_L16_MONO = 11;

    private final String mediaType;
    private final int port;
    private final String transportProtocol;
    private final int payloadType;
    private final HashMap<String, String> attributes;

    private int bitrate;
    @Nullable private String mediaTitle;
    @Nullable private String connection;
    @Nullable private String key;

    /**
     * Creates a new instance.
     *
     * @param mediaType The media type.
     * @param port The associated port number.
     * @param transportProtocol The protocol used for data transport.
     * @param payloadType The RTP payload type used for data transport.
     */
    public Builder(String mediaType, int port, String transportProtocol, int payloadType) {
      this.mediaType = mediaType;
      this.port = port;
      this.transportProtocol = transportProtocol;
      this.payloadType = payloadType;
      attributes = new HashMap<>();
      bitrate = Format.NO_VALUE;
    }

    /**
     * Sets {@link MediaDescription#mediaTitle}. The default is {@code null}.
     *
     * @param mediaTitle The assigned media title.
     * @return This builder.
     */
    public Builder setMediaTitle(String mediaTitle) {
      this.mediaTitle = mediaTitle;
      return this;
    }

    /**
     * Sets {@link MediaDescription#connection}. The default is {@code null}.
     *
     * @param connection The connection parameter.
     * @return This builder.
     */
    public Builder setConnection(String connection) {
      this.connection = connection;
      return this;
    }

    /**
     * Sets {@link MediaDescription#bitrate}. The default is {@link Format#NO_VALUE}.
     *
     * @param bitrate The estimated bitrate measured in bits per second.
     * @return This builder.
     */
    public Builder setBitrate(int bitrate) {
      this.bitrate = bitrate;
      return this;
    }

    /**
     * Sets {@link MediaDescription#key}. The default is {@code null}.
     *
     * @param key The encryption parameter.
     * @return This builder.
     */
    public Builder setKey(String key) {
      this.key = key;
      return this;
    }

    /**
     * Adds an attribute entry to {@link MediaDescription#attributes}.
     *
     * <p>Previously added attribute under the same name will be overwritten.
     *
     * @param attributeName The name of the attribute.
     * @param attributeValue The value of the attribute, or "" if the attribute bears no value.
     * @return This builder.
     */
    public Builder addAttribute(String attributeName, String attributeValue) {
      attributes.put(attributeName, attributeValue);
      return this;
    }

    /**
     * Builds a new {@link MediaDescription} instance.
     *
     * @throws IllegalStateException When the rtpmap attribute (RFC 2327 Page 22) is not set, or
     *     cannot be parsed.
     */
    public MediaDescription build() {
      try {
        RtpMapAttribute rtpMapAttribute =
            attributes.containsKey(ATTR_RTPMAP)
                ? RtpMapAttribute.parse(castNonNull(attributes.get(ATTR_RTPMAP)))
                : RtpMapAttribute.parse(getRtpMapStringByPayloadType(payloadType));
        return new MediaDescription(this, ImmutableMap.copyOf(attributes), rtpMapAttribute);
      } catch (ParserException e) {
        throw new IllegalStateException(e);
      }
    }

    private static String getRtpMapStringByPayloadType(int rtpPayloadType) {
      checkArgument(rtpPayloadType < 96);

      switch (rtpPayloadType) {
          // See RFC3551 Section 6.
        case RTP_STATIC_PAYLOAD_TYPE_PCMU:
          return constructAudioRtpMap(
              RTP_STATIC_PAYLOAD_TYPE_PCMU,
              /* mediaEncoding= */ "PCMU",
              /* clockRate= */ 8_000,
              /* channelCount= */ 1);
        case RTP_STATIC_PAYLOAD_TYPE_PCMA:
          return constructAudioRtpMap(
              RTP_STATIC_PAYLOAD_TYPE_PCMA,
              /* mediaEncoding= */ "PCMA",
              /* clockRate= */ 8_000,
              /* channelCount= */ 1);
        case RTP_STATIC_PAYLOAD_TYPE_L16_STEREO:
          return constructAudioRtpMap(
              RTP_STATIC_PAYLOAD_TYPE_L16_STEREO,
              /* mediaEncoding= */ "L16",
              /* clockRate= */ 44_100,
              /* channelCount= */ 2);
        case RTP_STATIC_PAYLOAD_TYPE_L16_MONO:
          return constructAudioRtpMap(
              RTP_STATIC_PAYLOAD_TYPE_L16_MONO,
              /* mediaEncoding= */ "L16",
              /* clockRate= */ 44_100,
              /* channelCount= */ 1);
        default:
          throw new IllegalStateException("Unsupported static paylod type " + rtpPayloadType);
      }
    }

    private static String constructAudioRtpMap(
        int payloadType, String mediaEncoding, int clockRate, int channelCount) {
      return Util.formatInvariant(
          RTP_MAP_ATTR_AUDIO_FMT, payloadType, mediaEncoding, clockRate, channelCount);
    }
  }

  /** The media types allowed in a SDP media description. */
  @Retention(RetentionPolicy.SOURCE)
  @StringDef({MEDIA_TYPE_VIDEO, MEDIA_TYPE_AUDIO})
  @Documented
  public @interface MediaType {}
  /** Audio media type. */
  public static final String MEDIA_TYPE_AUDIO = "audio";
  /** Video media type. */
  public static final String MEDIA_TYPE_VIDEO = "video";
  /** Default RTP/AVP profile. */
  public static final String RTP_AVP_PROFILE = "RTP/AVP";

  /** The {@link MediaType}. */
  @MediaType public final String mediaType;
  /** The associated port number. */
  public final int port;
  /** The protocol used for data transport. */
  public final String transportProtocol;
  /** The assigned RTP payload type. */
  public final int payloadType;
  /** The estimated connection bitrate in bits per second. */
  public final int bitrate;
  /** The assigned media title. */
  @Nullable public final String mediaTitle;
  /** The connection parameters. */
  @Nullable public final String connection;
  /** The encryption parameter. */
  @Nullable public final String key;
  /** The media-specific attributes. */
  public final ImmutableMap<String, String> attributes;
  /** The mandatory rtpmap attribute in the media description (RFC2327 Page 22). */
  public final RtpMapAttribute rtpMapAttribute;

  /** Creates a new instance. */
  private MediaDescription(
      Builder builder, ImmutableMap<String, String> attributes, RtpMapAttribute rtpMapAttribute) {
    this.mediaType = builder.mediaType;
    this.port = builder.port;
    this.transportProtocol = builder.transportProtocol;
    this.payloadType = builder.payloadType;
    this.mediaTitle = builder.mediaTitle;
    this.connection = builder.connection;
    this.bitrate = builder.bitrate;
    this.key = builder.key;
    this.attributes = attributes;
    this.rtpMapAttribute = rtpMapAttribute;
  }

  @Override
  public boolean equals(@Nullable Object o) {
    if (this == o) {
      return true;
    }

    if (o == null || getClass() != o.getClass()) {
      return false;
    }

    MediaDescription other = (MediaDescription) o;
    return mediaType.equals(other.mediaType)
        && port == other.port
        && transportProtocol.equals(other.transportProtocol)
        && payloadType == other.payloadType
        && bitrate == other.bitrate
        && attributes.equals(other.attributes)
        && rtpMapAttribute.equals(other.rtpMapAttribute)
        && Util.areEqual(mediaTitle, other.mediaTitle)
        && Util.areEqual(connection, other.connection)
        && Util.areEqual(key, other.key);
  }

  @Override
  public int hashCode() {
    int result = 7;
    result = 31 * result + mediaType.hashCode();
    result = 31 * result + port;
    result = 31 * result + transportProtocol.hashCode();
    result = 31 * result + payloadType;
    result = 31 * result + bitrate;
    result = 31 * result + attributes.hashCode();
    result = 31 * result + rtpMapAttribute.hashCode();
    result = 31 * result + (mediaTitle == null ? 0 : mediaTitle.hashCode());
    result = 31 * result + (connection == null ? 0 : connection.hashCode());
    result = 31 * result + (key == null ? 0 : key.hashCode());
    return result;
  }

  /**
   * Returns the FMTP attribute as a map of FMTP parameter names to values; or an empty map if the
   * {@link MediaDescription} does not contain any FMTP attribute.
   *
   * <p>FMTP format reference: RFC2327 Page 27. The spaces around the FMTP attribute delimiters are
   * removed.
   */
  public ImmutableMap<String, String> getFmtpParametersAsMap() {
    @Nullable String fmtpAttributeValue = attributes.get(ATTR_FMTP);
    if (fmtpAttributeValue == null) {
      return ImmutableMap.of();
    }

    // fmtp format: RFC2327 Page 27.
    String[] fmtpComponents = Util.splitAtFirst(fmtpAttributeValue, " ");
    checkArgument(fmtpComponents.length == 2, fmtpAttributeValue);

    // Format of the parameter: RFC3640 Section 4.4.1:
    //   <parameter name>=<value>[; <parameter name>=<value>].
    // Split with an explicit limit of 0 to handle an optional trailing semicolon.
    String[] parameters = fmtpComponents[1].split(";\s?", /* limit= */ 0);
    ImmutableMap.Builder<String, String> formatParametersBuilder = new ImmutableMap.Builder<>();
    for (String parameter : parameters) {
      // The parameter values can bear equal signs, so splitAtFirst must be used.
      String[] parameterPair = Util.splitAtFirst(parameter, "=");
      formatParametersBuilder.put(parameterPair[0], parameterPair[1]);
    }
    return formatParametersBuilder.buildOrThrow();
  }
}