RtspMessageUtil.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.RtspRequest.METHOD_ANNOUNCE;
import static androidx.media3.exoplayer.rtsp.RtspRequest.METHOD_DESCRIBE;
import static androidx.media3.exoplayer.rtsp.RtspRequest.METHOD_GET_PARAMETER;
import static androidx.media3.exoplayer.rtsp.RtspRequest.METHOD_OPTIONS;
import static androidx.media3.exoplayer.rtsp.RtspRequest.METHOD_PAUSE;
import static androidx.media3.exoplayer.rtsp.RtspRequest.METHOD_PLAY;
import static androidx.media3.exoplayer.rtsp.RtspRequest.METHOD_PLAY_NOTIFY;
import static androidx.media3.exoplayer.rtsp.RtspRequest.METHOD_RECORD;
import static androidx.media3.exoplayer.rtsp.RtspRequest.METHOD_REDIRECT;
import static androidx.media3.exoplayer.rtsp.RtspRequest.METHOD_SETUP;
import static androidx.media3.exoplayer.rtsp.RtspRequest.METHOD_SET_PARAMETER;
import static androidx.media3.exoplayer.rtsp.RtspRequest.METHOD_TEARDOWN;
import static androidx.media3.exoplayer.rtsp.RtspRequest.METHOD_UNSET;
import static com.google.common.base.Strings.nullToEmpty;
import static java.util.regex.Pattern.CASE_INSENSITIVE;

import android.net.Uri;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import com.google.common.base.Ascii;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/** Utility methods for RTSP messages. */
@UnstableApi
/* package */ final class RtspMessageUtil {
  /** Represents a RTSP Session header (RFC2326 Section 12.37). */
  public static final class RtspSessionHeader {
    /** The session ID. */
    public final String sessionId;
    /**
     * The session timeout, measured in milliseconds, {@link #DEFAULT_RTSP_TIMEOUT_MS} if not
     * specified in the Session header.
     */
    public final long timeoutMs;

    /** Creates a new instance. */
    public RtspSessionHeader(String sessionId, long timeoutMs) {
      this.sessionId = sessionId;
      this.timeoutMs = timeoutMs;
    }
  }

  /** Wraps username and password for authentication purposes. */
  public static final class RtspAuthUserInfo {
    /** The username. */
    public final String username;
    /** The password. */
    public final String password;

    /** Creates a new instance. */
    public RtspAuthUserInfo(String username, String password) {
      this.username = username;
      this.password = password;
    }
  }

  /** The default timeout, in milliseconds, defined for RTSP (RFC2326 Section 12.37). */
  public static final long DEFAULT_RTSP_TIMEOUT_MS = 60_000;

  // Status line pattern, see RFC2326 Section 6.1.
  private static final Pattern REQUEST_LINE_PATTERN = Pattern.compile("([A-Z_]+) (.*) RTSP/1\.0");

  // Status line pattern, see RFC2326 Section 7.1.
  private static final Pattern STATUS_LINE_PATTERN = Pattern.compile("RTSP/1\.0 (\d+) (.+)");

  // Content length header pattern, see RFC2326 Section 12.14.
  private static final Pattern CONTENT_LENGTH_HEADER_PATTERN =
      Pattern.compile("Content-Length:\s?(\d+)", CASE_INSENSITIVE);

  // Session header pattern, see RFC2326 Sections 3.4 and 12.37.
  private static final Pattern SESSION_HEADER_PATTERN =
      Pattern.compile("([\w$\-_.+]+)(?:;\s?timeout=(\d+))?");

  // WWW-Authenticate header pattern, see RFC2068 Sections 14.46 and RFC2069.
  private static final Pattern WWW_AUTHENTICATION_HEADER_DIGEST_PATTERN =
      Pattern.compile(
          "Digest realm=\"([^\"\x00-\x08\x0A-\x1f\x7f]+)\""
              + ",\s?(?:domain=\"(.+)\""
              + ",\s?)?nonce=\"([^\"\x00-\x08\x0A-\x1f\x7f]+)\""
              + "(?:,\s?opaque=\"([^\"\x00-\x08\x0A-\x1f\x7f]+)\")?");
  // WWW-Authenticate header pattern, see RFC2068 Section 11.1 and RFC2069.
  private static final Pattern WWW_AUTHENTICATION_HEADER_BASIC_PATTERN =
      Pattern.compile("Basic realm=\"([^\"\x00-\x08\x0A-\x1f\x7f]+)\"");

  private static final String RTSP_VERSION = "RTSP/1.0";
  private static final String LF = new String(new byte[] {Ascii.LF});
  private static final String CRLF = new String(new byte[] {Ascii.CR, Ascii.LF});

  /**
   * Serializes an {@link RtspRequest} to an {@link ImmutableList} of strings.
   *
   * <p>The {@link RtspRequest} must include the {@link RtspHeaders#CSEQ} header, or this method
   * throws {@link IllegalArgumentException}.
   *
   * @param request The {@link RtspRequest}.
   * @return A list of the lines of the {@link RtspRequest}, without line terminators (CRLF).
   */
  public static ImmutableList<String> serializeRequest(RtspRequest request) {
    checkArgument(request.headers.get(RtspHeaders.CSEQ) != null);

    ImmutableList.Builder<String> builder = new ImmutableList.Builder<>();
    // Request line.
    builder.add(
        Util.formatInvariant(
            "%s %s %s", toMethodString(request.method), request.uri, RTSP_VERSION));

    ImmutableListMultimap<String, String> headers = request.headers.asMultiMap();
    for (String headerName : headers.keySet()) {
      ImmutableList<String> headerValuesForName = headers.get(headerName);
      for (int i = 0; i < headerValuesForName.size(); i++) {
        builder.add(Util.formatInvariant("%s: %s", headerName, headerValuesForName.get(i)));
      }
    }
    // Empty line after headers.
    builder.add("");
    builder.add(request.messageBody);
    return builder.build();
  }

  /**
   * Serializes an {@link RtspResponse} to an {@link ImmutableList} of strings.
   *
   * <p>The {@link RtspResponse} must include the {@link RtspHeaders#CSEQ} header, or this method
   * throws {@link IllegalArgumentException}.
   *
   * @param response The {@link RtspResponse}.
   * @return A list of the lines of the {@link RtspResponse}, without line terminators (CRLF).
   */
  public static ImmutableList<String> serializeResponse(RtspResponse response) {
    checkArgument(response.headers.get(RtspHeaders.CSEQ) != null);

    ImmutableList.Builder<String> builder = new ImmutableList.Builder<>();
    // Request line.
    builder.add(
        Util.formatInvariant(
            "%s %s %s", RTSP_VERSION, response.status, getRtspStatusReasonPhrase(response.status)));

    ImmutableListMultimap<String, String> headers = response.headers.asMultiMap();
    for (String headerName : headers.keySet()) {
      ImmutableList<String> headerValuesForName = headers.get(headerName);
      for (int i = 0; i < headerValuesForName.size(); i++) {
        builder.add(Util.formatInvariant("%s: %s", headerName, headerValuesForName.get(i)));
      }
    }
    // Empty line after headers.
    builder.add("");
    builder.add(response.messageBody);
    return builder.build();
  }

  /**
   * Converts an RTSP message to a byte array.
   *
   * @param message The non-empty list of the lines of an RTSP message, with line terminators
   *     removed.
   */
  public static byte[] convertMessageToByteArray(List<String> message) {
    return Joiner.on(CRLF).join(message).getBytes(RtspMessageChannel.CHARSET);
  }

  /** Removes the user info from the supplied {@link Uri}. */
  public static Uri removeUserInfo(Uri uri) {
    if (uri.getUserInfo() == null) {
      return uri;
    }

    // The Uri must include a "@" if the user info is non-null.
    String authorityWithUserInfo = checkNotNull(uri.getAuthority());
    checkArgument(authorityWithUserInfo.contains("@"));
    String authority = Util.split(authorityWithUserInfo, "@")[1];
    return uri.buildUpon().encodedAuthority(authority).build();
  }

  /**
   * Parses the user info encapsulated in the RTSP {@link Uri}.
   *
   * @param uri The {@link Uri}.
   * @return The extracted {@link RtspAuthUserInfo}, {@code null} if the argument {@link Uri} does
   *     not contain userinfo, or it's not properly formatted.
   */
  @Nullable
  public static RtspAuthUserInfo parseUserInfo(Uri uri) {
    @Nullable String userInfo = uri.getUserInfo();
    if (userInfo == null) {
      return null;
    }
    if (userInfo.contains(":")) {
      String[] userInfoStrings = Util.splitAtFirst(userInfo, ":");
      return new RtspAuthUserInfo(userInfoStrings[0], userInfoStrings[1]);
    }
    return null;
  }

  /** Returns the byte array representation of a string, using RTSP's character encoding. */
  public static byte[] getStringBytes(String s) {
    return s.getBytes(RtspMessageChannel.CHARSET);
  }

  /** Returns the corresponding String representation of the {@link RtspRequest.Method} argument. */
  public static String toMethodString(@RtspRequest.Method int method) {
    switch (method) {
      case METHOD_ANNOUNCE:
        return "ANNOUNCE";
      case METHOD_DESCRIBE:
        return "DESCRIBE";
      case METHOD_GET_PARAMETER:
        return "GET_PARAMETER";
      case METHOD_OPTIONS:
        return "OPTIONS";
      case METHOD_PAUSE:
        return "PAUSE";
      case METHOD_PLAY:
        return "PLAY";
      case METHOD_PLAY_NOTIFY:
        return "PLAY_NOTIFY";
      case METHOD_RECORD:
        return "RECORD";
      case METHOD_REDIRECT:
        return "REDIRECT";
      case METHOD_SETUP:
        return "SETUP";
      case METHOD_SET_PARAMETER:
        return "SET_PARAMETER";
      case METHOD_TEARDOWN:
        return "TEARDOWN";
      case METHOD_UNSET:
      default:
        throw new IllegalStateException();
    }
  }

  private static @RtspRequest.Method int parseMethodString(String method) {
    switch (method) {
      case "ANNOUNCE":
        return METHOD_ANNOUNCE;
      case "DESCRIBE":
        return METHOD_DESCRIBE;
      case "GET_PARAMETER":
        return METHOD_GET_PARAMETER;
      case "OPTIONS":
        return METHOD_OPTIONS;
      case "PAUSE":
        return METHOD_PAUSE;
      case "PLAY":
        return METHOD_PLAY;
      case "PLAY_NOTIFY":
        return METHOD_PLAY_NOTIFY;
      case "RECORD":
        return METHOD_RECORD;
      case "REDIRECT":
        return METHOD_REDIRECT;
      case "SETUP":
        return METHOD_SETUP;
      case "SET_PARAMETER":
        return METHOD_SET_PARAMETER;
      case "TEARDOWN":
        return METHOD_TEARDOWN;
      default:
        throw new IllegalArgumentException();
    }
  }

  /**
   * Parses lines of a received RTSP response into an {@link RtspResponse} instance.
   *
   * @param lines The non-empty list of received lines, with line terminators removed.
   * @return The parsed {@link RtspResponse} object.
   */
  public static RtspResponse parseResponse(List<String> lines) {
    Matcher statusLineMatcher = STATUS_LINE_PATTERN.matcher(lines.get(0));
    checkArgument(statusLineMatcher.matches());

    int statusCode = Integer.parseInt(checkNotNull(statusLineMatcher.group(1)));
    // An empty line marks the boundary between header and body.
    int messageBodyOffset = lines.indexOf("");
    checkArgument(messageBodyOffset > 0);

    List<String> headerLines = lines.subList(1, messageBodyOffset);
    RtspHeaders headers = new RtspHeaders.Builder().addAll(headerLines).build();

    String messageBody = Joiner.on(CRLF).join(lines.subList(messageBodyOffset + 1, lines.size()));
    return new RtspResponse(statusCode, headers, messageBody);
  }

  /**
   * Parses lines of a received RTSP request into an {@link RtspRequest} instance.
   *
   * @param lines The non-empty list of received lines, with line terminators removed.
   * @return The parsed {@link RtspRequest} object.
   */
  public static RtspRequest parseRequest(List<String> lines) {
    Matcher requestMatcher = REQUEST_LINE_PATTERN.matcher(lines.get(0));
    checkArgument(requestMatcher.matches());

    @RtspRequest.Method int method = parseMethodString(checkNotNull(requestMatcher.group(1)));
    Uri requestUri = Uri.parse(checkNotNull(requestMatcher.group(2)));
    // An empty line marks the boundary between header and body.
    int messageBodyOffset = lines.indexOf("");
    checkArgument(messageBodyOffset > 0);

    List<String> headerLines = lines.subList(1, messageBodyOffset);
    RtspHeaders headers = new RtspHeaders.Builder().addAll(headerLines).build();

    String messageBody = Joiner.on(CRLF).join(lines.subList(messageBodyOffset + 1, lines.size()));
    return new RtspRequest(requestUri, method, headers, messageBody);
  }

  /** Returns whether the line is a valid RTSP start line. */
  public static boolean isRtspStartLine(String line) {
    return REQUEST_LINE_PATTERN.matcher(line).matches()
        || STATUS_LINE_PATTERN.matcher(line).matches();
  }

  /**
   * Returns whether the RTSP message is an RTSP response.
   *
   * @param lines The non-empty list of received lines, with line terminators removed.
   * @return Whether the lines represent an RTSP response.
   */
  public static boolean isRtspResponse(List<String> lines) {
    return STATUS_LINE_PATTERN.matcher(lines.get(0)).matches();
  }

  /** Returns the lines in an RTSP message body split by the line terminator used in body. */
  public static String[] splitRtspMessageBody(String body) {
    return Util.split(body, body.contains(CRLF) ? CRLF : LF);
  }

  /**
   * Returns the length in bytes if the line contains a Content-Length header, otherwise {@link
   * C#LENGTH_UNSET}.
   *
   * @throws ParserException If Content-Length cannot be parsed to an integer.
   */
  public static long parseContentLengthHeader(String line) throws ParserException {
    try {
      Matcher matcher = CONTENT_LENGTH_HEADER_PATTERN.matcher(line);
      if (matcher.find()) {
        return Long.parseLong(checkNotNull(matcher.group(1)));
      } else {
        return C.LENGTH_UNSET;
      }
    } catch (NumberFormatException e) {
      throw ParserException.createForMalformedManifest(line, e);
    }
  }

  /**
   * Parses the RTSP PUBLIC header into a list of RTSP methods.
   *
   * @param publicHeader The PUBLIC header content, null if not available.
   * @return The list of supported RTSP methods, encoded in {@link RtspRequest.Method}, or an empty
   *     list if the PUBLIC header is null.
   */
  public static ImmutableList<Integer> parsePublicHeader(@Nullable String publicHeader) {
    if (publicHeader == null) {
      return ImmutableList.of();
    }

    ImmutableList.Builder<Integer> methodListBuilder = new ImmutableList.Builder<>();
    for (String method : Util.split(publicHeader, ",\s?")) {
      methodListBuilder.add(parseMethodString(method));
    }
    return methodListBuilder.build();
  }

  /**
   * Parses a Session header in an RTSP message to {@link RtspSessionHeader}.
   *
   * <p>The format of the Session header is
   *
   * <pre>
   * Session: session-id[;timeout=delta-seconds]
   * </pre>
   *
   * @param headerValue The string represent the content without the header name (Session: ).
   * @return The parsed {@link RtspSessionHeader}.
   * @throws ParserException When the input header value does not follow the Session header format.
   */
  public static RtspSessionHeader parseSessionHeader(String headerValue) throws ParserException {
    Matcher matcher = SESSION_HEADER_PATTERN.matcher(headerValue);
    if (!matcher.matches()) {
      throw ParserException.createForMalformedManifest(headerValue, /* cause= */ null);
    }

    String sessionId = checkNotNull(matcher.group(1));
    // Optional parameter timeout.
    long timeoutMs = DEFAULT_RTSP_TIMEOUT_MS;
    @Nullable String timeoutString;
    if ((timeoutString = matcher.group(2)) != null) {
      try {
        timeoutMs = Integer.parseInt(timeoutString) * C.MILLIS_PER_SECOND;
      } catch (NumberFormatException e) {
        throw ParserException.createForMalformedManifest(headerValue, e);
      }
    }

    return new RtspSessionHeader(sessionId, timeoutMs);
  }

  /**
   * Parses a WWW-Authenticate header.
   *
   * <p>Reference RFC2068 Section 14.46 for WWW-Authenticate header. Only digest and basic
   * authentication mechanisms are supported.
   *
   * @param headerValue The string representation of the content, without the header name
   *     (WWW-Authenticate: ).
   * @return The parsed {@link RtspAuthenticationInfo}.
   * @throws ParserException When the input header value does not follow the WWW-Authenticate header
   *     format, or is not using either Basic or Digest mechanisms.
   */
  public static RtspAuthenticationInfo parseWwwAuthenticateHeader(String headerValue)
      throws ParserException {
    Matcher matcher = WWW_AUTHENTICATION_HEADER_DIGEST_PATTERN.matcher(headerValue);
    if (matcher.find()) {
      return new RtspAuthenticationInfo(
          RtspAuthenticationInfo.DIGEST,
          /* realm= */ checkNotNull(matcher.group(1)),
          /* nonce= */ checkNotNull(matcher.group(3)),
          /* opaque= */ nullToEmpty(matcher.group(4)));
    }
    matcher = WWW_AUTHENTICATION_HEADER_BASIC_PATTERN.matcher(headerValue);
    if (matcher.matches()) {
      return new RtspAuthenticationInfo(
          RtspAuthenticationInfo.BASIC,
          /* realm= */ checkNotNull(matcher.group(1)),
          /* nonce= */ "",
          /* opaque= */ "");
    }
    throw ParserException.createForMalformedManifest(
        "Invalid WWW-Authenticate header " + headerValue, /* cause= */ null);
  }

  /**
   * Throws {@link ParserException#createForMalformedManifest ParserException} if {@code expression}
   * evaluates to false.
   *
   * @param expression The expression to evaluate.
   * @param message The error message.
   * @throws ParserException If {@code expression} is false.
   */
  public static void checkManifestExpression(boolean expression, @Nullable String message)
      throws ParserException {
    if (!expression) {
      throw ParserException.createForMalformedManifest(message, /* cause= */ null);
    }
  }

  private static String getRtspStatusReasonPhrase(int statusCode) {
    switch (statusCode) {
      case 200:
        return "OK";
      case 301:
        return "Move Permanently";
      case 302:
        return "Move Temporarily";
      case 400:
        return "Bad Request";
      case 401:
        return "Unauthorized";
      case 404:
        return "Not Found";
      case 405:
        return "Method Not Allowed";
      case 454:
        return "Session Not Found";
      case 455:
        return "Method Not Valid In This State";
      case 456:
        return "Header Field Not Valid";
      case 457:
        return "Invalid Range";
      case 461:
        return "Unsupported Transport";
      case 500:
        return "Internal Server Error";
      case 505:
        return "RTSP Version Not Supported";
      default:
        throw new IllegalArgumentException();
    }
  }

  /**
   * Parses the string argument as an integer, wraps the potential {@link NumberFormatException} in
   * {@link ParserException}.
   */
  public static int parseInt(String intString) throws ParserException {
    try {
      return Integer.parseInt(intString);
    } catch (NumberFormatException e) {
      throw ParserException.createForMalformedManifest(intString, e);
    }
  }

  private RtspMessageUtil() {}
}