SessionDescriptionParser.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.Assertions.checkNotNull;
import static androidx.media3.exoplayer.rtsp.SessionDescription.SUPPORTED_SDP_VERSION;
import static com.google.common.base.Strings.nullToEmpty;

import android.net.Uri;
import androidx.annotation.Nullable;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/** Parses a String based SDP message into {@link SessionDescription}. */
@UnstableApi
/* package */ final class SessionDescriptionParser {
  // SDP line always starts with an one letter tag, followed by an equal sign. The information
  // under the given tag follows an optional space.
  private static final Pattern SDP_LINE_PATTERN = Pattern.compile("([a-z])=\s?(.+)");
  // Matches an attribute line (with a= sdp tag removed. Example: range:npt=0-50.0).
  // Attribute can also be a flag, i.e. without a value, like recvonly. Reference RFC4566 Section 9
  // Page 43, under "token-char".
  private static final Pattern ATTRIBUTE_PATTERN =
      Pattern.compile(
          "([\x21\x23-\x27\x2a\x2b\x2d\x2e\x30-\x39\x41-\x5a\x5e-\x7e]+)(?::(.*))?");
  // SDP media description line: <mediaType> <port> <transmissionProtocol> <rtpPayloadType>
  // For instance: audio 0 RTP/AVP 97
  private static final Pattern MEDIA_DESCRIPTION_PATTERN =
      Pattern.compile("(\S+)\s(\S+)\s(\S+)\s(\S+)");

  private static final String VERSION_TYPE = "v";
  private static final String ORIGIN_TYPE = "o";
  private static final String SESSION_TYPE = "s";
  private static final String INFORMATION_TYPE = "i";
  private static final String URI_TYPE = "u";
  private static final String EMAIL_TYPE = "e";
  private static final String PHONE_NUMBER_TYPE = "p";
  private static final String CONNECTION_TYPE = "c";
  private static final String BANDWIDTH_TYPE = "b";
  private static final String TIMING_TYPE = "t";
  private static final String KEY_TYPE = "k";
  private static final String ATTRIBUTE_TYPE = "a";
  private static final String MEDIA_TYPE = "m";
  private static final String REPEAT_TYPE = "r";
  private static final String ZONE_TYPE = "z";

  /**
   * Parses a String based SDP message into {@link SessionDescription}.
   *
   * @throws ParserException On SDP message line that cannot be parsed, or when one or more of the
   *     mandatory SDP fields {@link SessionDescription#timing}, {@link SessionDescription#origin}
   *     and {@link SessionDescription#sessionName} are not set.
   */
  public static SessionDescription parse(String sdpString) throws ParserException {
    SessionDescription.Builder sessionDescriptionBuilder = new SessionDescription.Builder();
    @Nullable MediaDescription.Builder mediaDescriptionBuilder = null;

    // Lines are separated by an CRLF.
    for (String line : RtspMessageUtil.splitRtspMessageBody(sdpString)) {
      if ("".equals(line)) {
        continue;
      }

      Matcher matcher = SDP_LINE_PATTERN.matcher(line);
      if (!matcher.matches()) {
        throw ParserException.createForMalformedManifest(
            "Malformed SDP line: " + line, /* cause= */ null);
      }

      String sdpType = checkNotNull(matcher.group(1));
      String sdpValue = checkNotNull(matcher.group(2));

      switch (sdpType) {
        case VERSION_TYPE:
          if (!SUPPORTED_SDP_VERSION.equals(sdpValue)) {
            throw ParserException.createForMalformedManifest(
                String.format("SDP version %s is not supported.", sdpValue), /* cause= */ null);
          }
          break;

        case ORIGIN_TYPE:
          sessionDescriptionBuilder.setOrigin(sdpValue);
          break;

        case SESSION_TYPE:
          sessionDescriptionBuilder.setSessionName(sdpValue);
          break;

        case INFORMATION_TYPE:
          if (mediaDescriptionBuilder == null) {
            sessionDescriptionBuilder.setSessionInfo(sdpValue);
          } else {
            mediaDescriptionBuilder.setMediaTitle(sdpValue);
          }
          break;

        case URI_TYPE:
          sessionDescriptionBuilder.setUri(Uri.parse(sdpValue));
          break;

        case EMAIL_TYPE:
          sessionDescriptionBuilder.setEmailAddress(sdpValue);
          break;

        case PHONE_NUMBER_TYPE:
          sessionDescriptionBuilder.setPhoneNumber(sdpValue);
          break;

        case CONNECTION_TYPE:
          if (mediaDescriptionBuilder == null) {
            sessionDescriptionBuilder.setConnection(sdpValue);
          } else {
            mediaDescriptionBuilder.setConnection(sdpValue);
          }
          break;

        case BANDWIDTH_TYPE:
          String[] bandwidthComponents = Util.split(sdpValue, ":\s?");
          checkArgument(bandwidthComponents.length == 2);
          int bitrateKbps = Integer.parseInt(bandwidthComponents[1]);

          // Converting kilobits per second to bits per second.
          if (mediaDescriptionBuilder == null) {
            sessionDescriptionBuilder.setBitrate(bitrateKbps * 1000);
          } else {
            mediaDescriptionBuilder.setBitrate(bitrateKbps * 1000);
          }
          break;

        case TIMING_TYPE:
          sessionDescriptionBuilder.setTiming(sdpValue);
          break;

        case KEY_TYPE:
          if (mediaDescriptionBuilder == null) {
            sessionDescriptionBuilder.setKey(sdpValue);
          } else {
            mediaDescriptionBuilder.setKey(sdpValue);
          }
          break;

        case ATTRIBUTE_TYPE:
          matcher = ATTRIBUTE_PATTERN.matcher(sdpValue);
          if (!matcher.matches()) {
            throw ParserException.createForMalformedManifest(
                "Malformed Attribute line: " + line, /* cause= */ null);
          }

          String attributeName = checkNotNull(matcher.group(1));
          // The second catching group is optional and thus could be null.
          String attributeValue = nullToEmpty(matcher.group(2));

          if (mediaDescriptionBuilder == null) {
            sessionDescriptionBuilder.addAttribute(attributeName, attributeValue);
          } else {
            mediaDescriptionBuilder.addAttribute(attributeName, attributeValue);
          }
          break;

        case MEDIA_TYPE:
          if (mediaDescriptionBuilder != null) {
            addMediaDescriptionToSession(sessionDescriptionBuilder, mediaDescriptionBuilder);
          }
          mediaDescriptionBuilder = parseMediaDescriptionLine(sdpValue);
          break;
        case REPEAT_TYPE:
        case ZONE_TYPE:
        default:
          // Not handled.
      }
    }

    if (mediaDescriptionBuilder != null) {
      addMediaDescriptionToSession(sessionDescriptionBuilder, mediaDescriptionBuilder);
    }

    try {
      return sessionDescriptionBuilder.build();
    } catch (IllegalArgumentException | IllegalStateException e) {
      throw ParserException.createForMalformedManifest(/* message= */ null, e);
    }
  }

  private static void addMediaDescriptionToSession(
      SessionDescription.Builder sessionDescriptionBuilder,
      MediaDescription.Builder mediaDescriptionBuilder)
      throws ParserException {
    try {
      sessionDescriptionBuilder.addMediaDescription(mediaDescriptionBuilder.build());
    } catch (IllegalArgumentException | IllegalStateException e) {
      throw ParserException.createForMalformedManifest(/* message= */ null, e);
    }
  }

  private static MediaDescription.Builder parseMediaDescriptionLine(String line)
      throws ParserException {
    Matcher matcher = MEDIA_DESCRIPTION_PATTERN.matcher(line);
    if (!matcher.matches()) {
      throw ParserException.createForMalformedManifest(
          "Malformed SDP media description line: " + line, /* cause= */ null);
    }
    String mediaType = checkNotNull(matcher.group(1));
    String portString = checkNotNull(matcher.group(2));
    String transportProtocol = checkNotNull(matcher.group(3));
    String payloadTypeString = checkNotNull(matcher.group(4));

    try {
      return new MediaDescription.Builder(
          mediaType,
          Integer.parseInt(portString),
          transportProtocol,
          Integer.parseInt(payloadTypeString));
    } catch (NumberFormatException e) {
      throw ParserException.createForMalformedManifest(
          "Malformed SDP media description line: " + line, e);
    }
  }

  /** Prevents initialization. */
  private SessionDescriptionParser() {}
}