diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 1448c8f772..d4f0444bcc 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -132,6 +132,8 @@ `DashMediaSource.Factory`. * We don't currently support using platform extractors with SmoothStreaming. +* RTSP + * Release the initial version of ExoPlayer's RTSP support. ### 2.13.3 (2021-04-14) diff --git a/core_settings.gradle b/core_settings.gradle index 99cfc5b734..c0c19abf80 100644 --- a/core_settings.gradle +++ b/core_settings.gradle @@ -27,6 +27,7 @@ include modulePrefix + 'library-core' include modulePrefix + 'library-dash' include modulePrefix + 'library-extractor' include modulePrefix + 'library-hls' +include modulePrefix + 'library-rtsp' include modulePrefix + 'library-smoothstreaming' include modulePrefix + 'library-transformer' include modulePrefix + 'library-ui' @@ -55,6 +56,7 @@ project(modulePrefix + 'library-core').projectDir = new File(rootDir, 'library/c project(modulePrefix + 'library-dash').projectDir = new File(rootDir, 'library/dash') project(modulePrefix + 'library-extractor').projectDir = new File(rootDir, 'library/extractor') project(modulePrefix + 'library-hls').projectDir = new File(rootDir, 'library/hls') +project(modulePrefix + 'library-rtsp').projectDir = new File(rootDir, 'library/rtsp') project(modulePrefix + 'library-smoothstreaming').projectDir = new File(rootDir, 'library/smoothstreaming') project(modulePrefix + 'library-transformer').projectDir = new File(rootDir, 'library/transformer') project(modulePrefix + 'library-ui').projectDir = new File(rootDir, 'library/ui') diff --git a/library/common/src/main/java/com/google/android/exoplayer2/C.java b/library/common/src/main/java/com/google/android/exoplayer2/C.java index 75780bc68f..eba6c0cfcd 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/C.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/C.java @@ -607,11 +607,11 @@ public final class C { /** * Represents a streaming or other media type. One of {@link #TYPE_DASH}, {@link #TYPE_SS}, {@link - * #TYPE_HLS} or {@link #TYPE_OTHER}. + * #TYPE_HLS}, {@link #TYPE_RTSP} or {@link #TYPE_OTHER}. */ @Documented @Retention(RetentionPolicy.SOURCE) - @IntDef({TYPE_DASH, TYPE_SS, TYPE_HLS, TYPE_OTHER}) + @IntDef({TYPE_DASH, TYPE_SS, TYPE_HLS, TYPE_RTSP, TYPE_OTHER}) public @interface ContentType {} /** * Value returned by {@link Util#inferContentType(String)} for DASH manifests. @@ -625,11 +625,13 @@ public final class C { * Value returned by {@link Util#inferContentType(String)} for HLS manifests. */ public static final int TYPE_HLS = 2; + /** Value returned by {@link Util#inferContentType(String)} for RTSP. */ + public static final int TYPE_RTSP = 3; /** * Value returned by {@link Util#inferContentType(String)} for files other than DASH, HLS or - * Smooth Streaming manifests. + * Smooth Streaming manifests, or RTSP URIs. */ - public static final int TYPE_OTHER = 3; + public static final int TYPE_OTHER = 4; /** * A return value for methods where the end of an input was encountered. diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java index 56da8a42ad..8615bc826e 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java @@ -112,6 +112,7 @@ public final class MimeTypes { public static final String APPLICATION_EXIF = BASE_TYPE_APPLICATION + "/x-exif"; public static final String APPLICATION_ICY = BASE_TYPE_APPLICATION + "/x-icy"; public static final String APPLICATION_AIT = BASE_TYPE_APPLICATION + "/vnd.dvb.ait"; + public static final String APPLICATION_RTSP = BASE_TYPE_APPLICATION + "/x-rtsp"; public static final String IMAGE_JPEG = BASE_TYPE_IMAGE + "/jpeg"; diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java index 5f00757cb5..07c7a0a776 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -1800,6 +1800,11 @@ public final class Util { */ @ContentType public static int inferContentType(Uri uri) { + @Nullable String scheme = uri.getScheme(); + if (scheme != null && Ascii.equalsIgnoreCase("rtsp", scheme)) { + return C.TYPE_RTSP; + } + @Nullable String path = uri.getPath(); return path == null ? C.TYPE_OTHER : inferContentType(path); } @@ -1852,6 +1857,8 @@ public final class Util { return C.TYPE_HLS; case MimeTypes.APPLICATION_SS: return C.TYPE_SS; + case MimeTypes.APPLICATION_RTSP: + return C.TYPE_RTSP; default: return C.TYPE_OTHER; } diff --git a/library/core/proguard-rules.txt b/library/core/proguard-rules.txt index 4e422065d8..90f9d11130 100644 --- a/library/core/proguard-rules.txt +++ b/library/core/proguard-rules.txt @@ -64,3 +64,7 @@ -keepclasseswithmembers class com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource$Factory { (com.google.android.exoplayer2.upstream.DataSource$Factory); } +-dontnote com.google.android.exoplayer2.source.rtsp.RtspMediaSource$Factory +-keepclasseswithmembers class com.google.android.exoplayer2.source.rtsp.RtspMediaSource$Factory { + (); +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java index 60ecafada9..a7bd656294 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java @@ -468,6 +468,14 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { } catch (Exception e) { // Expected if the app was built without the hls module. } + try { + Class factoryClazz = + Class.forName("com.google.android.exoplayer2.source.rtsp.RtspMediaSource$Factory") + .asSubclass(MediaSourceFactory.class); + factories.put(C.TYPE_RTSP, factoryClazz.getConstructor().newInstance()); + } catch (Exception e) { + // Expected if the app was built without the RTSP module. + } // LINT.ThenChange(../../../../../../../../proguard-rules.txt) factories.put( C.TYPE_OTHER, new ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)); diff --git a/library/rtsp/build.gradle b/library/rtsp/build.gradle new file mode 100644 index 0000000000..c97391a858 --- /dev/null +++ b/library/rtsp/build.gradle @@ -0,0 +1,49 @@ +// Copyright 2020 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. +apply from: "$gradle.ext.exoplayerSettingsDir/common_library_config.gradle" + +android { + buildTypes { + debug { + testCoverageEnabled = true + } + } + + sourceSets.test.assets.srcDir '../../testdata/src/test/assets/' +} + +dependencies { + implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion + compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion + implementation project(modulePrefix + 'library-core') + testImplementation project(modulePrefix + 'robolectricutils') + testImplementation project(modulePrefix + 'testutils') + testImplementation project(modulePrefix + 'testdata') + testImplementation 'org.checkerframework:checker-qual:' + checkerframeworkVersion + testImplementation 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion + testImplementation 'org.robolectric:robolectric:' + robolectricVersion +} + +ext { + javadocTitle = 'RTSP module' +} +apply from: '../../javadoc_library.gradle' + +ext { + releaseArtifact = 'exoplayer-rtsp' + releaseDescription = 'The ExoPlayer library RTSP module.' +} +apply from: '../../publish.gradle' diff --git a/library/rtsp/src/main/AndroidManifest.xml b/library/rtsp/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..4becfcc68e --- /dev/null +++ b/library/rtsp/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspClient.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspClient.java new file mode 100644 index 0000000000..b1ad45c86e --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspClient.java @@ -0,0 +1,550 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp; + +import static com.google.android.exoplayer2.source.rtsp.message.RtspMessageChannel.DEFAULT_RTSP_PORT; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_ANNOUNCE; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_DESCRIBE; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_GET_PARAMETER; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_OPTIONS; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_PAUSE; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_PLAY; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_PLAY_NOTIFY; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_RECORD; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_REDIRECT; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_SETUP; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_SET_PARAMETER; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_TEARDOWN; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_UNSET; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; +import static com.google.common.base.Strings.nullToEmpty; + +import android.net.Uri; +import android.os.Handler; +import android.util.SparseArray; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.source.rtsp.RtspMediaPeriod.RtpLoadInfo; +import com.google.android.exoplayer2.source.rtsp.RtspMediaSource.RtspPlaybackException; +import com.google.android.exoplayer2.source.rtsp.message.RtspDescribeResponse; +import com.google.android.exoplayer2.source.rtsp.message.RtspHeaders; +import com.google.android.exoplayer2.source.rtsp.message.RtspMessageChannel; +import com.google.android.exoplayer2.source.rtsp.message.RtspMessageUtil; +import com.google.android.exoplayer2.source.rtsp.message.RtspMessageUtil.RtspSessionHeader; +import com.google.android.exoplayer2.source.rtsp.message.RtspOptionsResponse; +import com.google.android.exoplayer2.source.rtsp.message.RtspPlayResponse; +import com.google.android.exoplayer2.source.rtsp.message.RtspRequest; +import com.google.android.exoplayer2.source.rtsp.message.RtspResponse; +import com.google.android.exoplayer2.source.rtsp.message.RtspSetupResponse; +import com.google.android.exoplayer2.source.rtsp.rtp.RtpPayloadFormat; +import com.google.android.exoplayer2.source.rtsp.sdp.MediaDescription; +import com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription; +import com.google.android.exoplayer2.source.rtsp.sdp.SessionDescriptionParser; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.io.Closeable; +import java.io.IOException; +import java.net.Socket; +import java.util.ArrayDeque; +import java.util.List; +import java.util.Map; +import javax.net.SocketFactory; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** The RTSP client. */ +/* package */ final class RtspClient implements Closeable { + + private static final long DEFAULT_RTSP_KEEP_ALIVE_INTERVAL_MS = 30_000; + + /** A listener for session information update. */ + public interface SessionInfoListener { + /** Called when the session information is available. */ + void onSessionTimelineUpdated(RtspSessionTiming timing, ImmutableList tracks); + /** + * Called when failed to get session information from the RTSP server, or when error happened + * during updating the session timeline. + */ + void onSessionTimelineRequestFailed(String message, @Nullable Throwable cause); + } + + /** A listener for playback events. */ + public interface PlaybackEventListener { + /** Called when setup is completed and playback can start. */ + void onRtspSetupCompleted(); + + /** + * Called when a PLAY request is acknowledged by the server and playback can start. + * + * @param startPositionUs The server-supplied start position in microseconds. + * @param trackTimingList The list of {@link RtspTrackTiming} for the playing tracks. + */ + void onPlaybackStarted(long startPositionUs, ImmutableList trackTimingList); + + /** Called when errors are encountered during playback. */ + void onPlaybackError(RtspPlaybackException error); + } + + private final SessionInfoListener sessionInfoListener; + private final Uri uri; + @Nullable private final String userAgent; + private final RtspMessageChannel messageChannel; + private final ArrayDeque pendingSetupRtpLoadInfos; + // TODO(b/172331505) Add a timeout monitor for pending requests. + private final SparseArray pendingRequests; + private final MessageSender messageSender; + + private @MonotonicNonNull PlaybackEventListener playbackEventListener; + private @MonotonicNonNull String sessionId; + @Nullable private KeepAliveMonitor keepAliveMonitor; + private boolean hasUpdatedTimelineAndTracks; + private long pendingSeekPositionUs; + + /** + * Creates a new instance. + * + *

The constructor must be called on the playback thread. The thread is also where {@link + * SessionInfoListener} and {@link PlaybackEventListener} events are sent. User must {@link + * #start} the client, and {@link #close} it when done. + * + *

Note: all method invocations must be made from the playback thread. + * + * @param sessionInfoListener The {@link SessionInfoListener}. + * @param userAgent The user agent that will be used if needed, or {@code null} for the fallback + * to use the default user agent of the underlying platform. + * @param uri The RTSP playback URI. + */ + public RtspClient(SessionInfoListener sessionInfoListener, @Nullable String userAgent, Uri uri) { + this.sessionInfoListener = sessionInfoListener; + this.uri = RtspMessageUtil.removeUserInfo(uri); + this.userAgent = userAgent; + messageChannel = new RtspMessageChannel(new MessageListener()); + pendingSetupRtpLoadInfos = new ArrayDeque<>(); + pendingRequests = new SparseArray<>(); + messageSender = new MessageSender(); + pendingSeekPositionUs = C.TIME_UNSET; + } + + /** + * Starts the client and sends an OPTIONS request. + * + *

Calls {@link #close()} if {@link IOException} is thrown when opening a connection to the + * supplied {@link Uri}. + * + * @throws IOException When failed to open a connection to the supplied {@link Uri}. + */ + public void start() throws IOException { + checkArgument(uri.getHost() != null); + int rtspPort = uri.getPort() > 0 ? uri.getPort() : DEFAULT_RTSP_PORT; + Socket socket = SocketFactory.getDefault().createSocket(checkNotNull(uri.getHost()), rtspPort); + try { + messageChannel.openSocket(socket); + } catch (IOException e) { + Util.closeQuietly(messageChannel); + throw e; + } + messageSender.sendOptionsRequest(uri, sessionId); + } + + /** Sets the {@link PlaybackEventListener} to receive playback events. */ + public void setPlaybackEventListener(PlaybackEventListener playbackEventListener) { + this.playbackEventListener = playbackEventListener; + } + + /** + * Triggers RTSP SETUP requests after track selection. + * + *

A {@link PlaybackEventListener} must be set via {@link #setPlaybackEventListener} before + * calling this method. All selected tracks (represented by {@link RtpLoadInfo}) must have valid + * transport. + * + * @param loadInfos A list of selected tracks represented by {@link RtpLoadInfo}. + */ + public void setupSelectedTracks(List loadInfos) { + pendingSetupRtpLoadInfos.addAll(loadInfos); + continueSetupRtspTrack(); + } + + /** + * Starts RTSP playback by sending RTSP PLAY request. + * + * @param offsetMs The playback offset in milliseconds, with respect to the stream start position. + */ + public void startPlayback(long offsetMs) { + messageSender.sendPlayRequest(uri, offsetMs, checkNotNull(sessionId)); + } + + /** + * Seeks to a specific time using RTSP. + * + *

Call this method only when in-buffer seek is not feasible. An RTSP PAUSE, and an RTSP PLAY + * request will be sent out to perform a seek on the server side. + * + * @param positionUs The seek time measured in microseconds. + */ + public void seekToUs(long positionUs) { + messageSender.sendPauseRequest(uri, checkNotNull(sessionId)); + pendingSeekPositionUs = positionUs; + } + + @Override + public void close() throws IOException { + if (keepAliveMonitor != null) { + // Playback has started. We have to stop the periodic keep alive and send a TEARDOWN so that + // the RTSP server stops sending RTP packets and frees up resources. + keepAliveMonitor.close(); + keepAliveMonitor = null; + messageSender.sendTeardownRequest(uri, checkNotNull(sessionId)); + } + messageChannel.close(); + } + + private void continueSetupRtspTrack() { + @Nullable RtpLoadInfo loadInfo = pendingSetupRtpLoadInfos.pollFirst(); + if (loadInfo == null) { + checkNotNull(playbackEventListener).onRtspSetupCompleted(); + return; + } + messageSender.sendSetupRequest(loadInfo.getTrackUri(), loadInfo.getTransport(), sessionId); + } + + /** + * Returns whether the RTSP server supports the DESCRIBE method. + * + *

The DESCRIBE method is marked "recommended to implement" in RFC2326 Section 10. We assume + * the server supports DESCRIBE, if the OPTIONS response does not include a PUBLIC header. + * + * @param serverSupportedMethods A list of RTSP methods (as defined in RFC2326 Section 10, encoded + * as {@link RtspRequest.Method}) that are supported by the RTSP server. + */ + private static boolean serverSupportsDescribe(List serverSupportedMethods) { + return serverSupportedMethods.isEmpty() || serverSupportedMethods.contains(METHOD_DESCRIBE); + } + + /** + * Gets the included {@link RtspMediaTrack RtspMediaTracks} from a {@link SessionDescription}. + * + * @param sessionDescription The {@link SessionDescription}. + * @param uri The RTSP playback URI. + */ + private static ImmutableList buildTrackList( + SessionDescription sessionDescription, Uri uri) { + ImmutableList.Builder trackListBuilder = new ImmutableList.Builder<>(); + for (int i = 0; i < sessionDescription.mediaDescriptionList.size(); i++) { + MediaDescription mediaDescription = sessionDescription.mediaDescriptionList.get(i); + // Includes tracks with supported formats only. + if (RtpPayloadFormat.isFormatSupported(mediaDescription)) { + trackListBuilder.add(new RtspMediaTrack(mediaDescription, uri)); + } + } + return trackListBuilder.build(); + } + + private final class MessageSender { + + private int cSeq; + + public void sendOptionsRequest(Uri uri, @Nullable String sessionId) { + sendRequest( + getRequestWithCommonHeaders( + METHOD_OPTIONS, sessionId, /* additionalHeaders= */ ImmutableMap.of(), uri)); + } + + public void sendDescribeRequest(Uri uri, @Nullable String sessionId) { + sendRequest( + getRequestWithCommonHeaders( + METHOD_DESCRIBE, sessionId, /* additionalHeaders= */ ImmutableMap.of(), uri)); + } + + public void sendSetupRequest(Uri trackUri, String transport, @Nullable String sessionId) { + sendRequest( + getRequestWithCommonHeaders( + METHOD_SETUP, + sessionId, + /* additionalHeaders= */ ImmutableMap.of(RtspHeaders.TRANSPORT, transport), + trackUri)); + } + + public void sendPlayRequest(Uri uri, long offsetMs, String sessionId) { + sendRequest( + getRequestWithCommonHeaders( + METHOD_PLAY, + sessionId, + /* additionalHeaders= */ ImmutableMap.of( + RtspHeaders.RANGE, RtspSessionTiming.getOffsetStartTimeTiming(offsetMs)), + uri)); + } + + public void sendTeardownRequest(Uri uri, String sessionId) { + sendRequest( + getRequestWithCommonHeaders( + METHOD_TEARDOWN, sessionId, /* additionalHeaders= */ ImmutableMap.of(), uri)); + } + + public void sendPauseRequest(Uri uri, String sessionId) { + sendRequest( + getRequestWithCommonHeaders( + METHOD_PAUSE, sessionId, /* additionalHeaders= */ ImmutableMap.of(), uri)); + } + + private RtspRequest getRequestWithCommonHeaders( + @RtspRequest.Method int method, + @Nullable String sessionId, + Map additionalHeaders, + Uri uri) { + RtspHeaders.Builder headersBuilder = new RtspHeaders.Builder(); + headersBuilder.add(RtspHeaders.CSEQ, String.valueOf(cSeq++)); + + if (userAgent != null) { + headersBuilder.add(RtspHeaders.USER_AGENT, userAgent); + } + + if (sessionId != null) { + headersBuilder.add(RtspHeaders.SESSION, sessionId); + } + + headersBuilder.addAll(additionalHeaders); + return new RtspRequest(uri, method, headersBuilder.build(), /* messageBody= */ ""); + } + + private void sendRequest(RtspRequest request) { + int cSeq = Integer.parseInt(checkNotNull(request.headers.get(RtspHeaders.CSEQ))); + checkState(pendingRequests.get(cSeq) == null); + pendingRequests.append(cSeq, request); + messageChannel.send(RtspMessageUtil.serializeRequest(request)); + } + } + + private final class MessageListener implements RtspMessageChannel.MessageListener { + + @Override + public void onRtspMessageReceived(List message) { + RtspResponse response = RtspMessageUtil.parseResponse(message); + + int cSeq = Integer.parseInt(checkNotNull(response.headers.get(RtspHeaders.CSEQ))); + + @Nullable RtspRequest matchingRequest = pendingRequests.get(cSeq); + if (matchingRequest == null) { + return; + } else { + pendingRequests.remove(cSeq); + } + + @RtspRequest.Method int requestMethod = matchingRequest.method; + + if (response.status != 200) { + dispatchRtspError( + new RtspPlaybackException( + RtspMessageUtil.toMethodString(requestMethod) + " " + response.status)); + return; + } + + try { + switch (requestMethod) { + case METHOD_OPTIONS: + onOptionsResponseReceived( + new RtspOptionsResponse( + response.status, + RtspMessageUtil.parsePublicHeader(response.headers.get(RtspHeaders.PUBLIC)))); + break; + + case METHOD_DESCRIBE: + onDescribeResponseReceived( + new RtspDescribeResponse( + response.status, SessionDescriptionParser.parse(response.messageBody))); + break; + + case METHOD_SETUP: + @Nullable String sessionHeaderString = response.headers.get(RtspHeaders.SESSION); + @Nullable String transportHeaderString = response.headers.get(RtspHeaders.TRANSPORT); + if (sessionHeaderString == null || transportHeaderString == null) { + throw new ParserException(); + } + + RtspSessionHeader sessionHeader = + RtspMessageUtil.parseSessionHeader(sessionHeaderString); + onSetupResponseReceived( + new RtspSetupResponse(response.status, sessionHeader, transportHeaderString)); + break; + + case METHOD_PLAY: + // Range header is optional for a PLAY response (RFC2326 Section 12). + @Nullable String startTimingString = response.headers.get(RtspHeaders.RANGE); + RtspSessionTiming timing = + startTimingString == null + ? RtspSessionTiming.DEFAULT + : RtspSessionTiming.parseTiming(startTimingString); + @Nullable String rtpInfoString = response.headers.get(RtspHeaders.RTP_INFO); + ImmutableList trackTimingList = + rtpInfoString == null + ? ImmutableList.of() + : RtspTrackTiming.parseTrackTiming(rtpInfoString); + onPlayResponseReceived(new RtspPlayResponse(response.status, timing, trackTimingList)); + break; + + case METHOD_GET_PARAMETER: + onGetParameterResponseReceived(response); + break; + + case METHOD_TEARDOWN: + onTeardownResponseReceived(response); + break; + + case METHOD_PAUSE: + onPauseResponseReceived(response); + break; + + case METHOD_PLAY_NOTIFY: + case METHOD_RECORD: + case METHOD_REDIRECT: + case METHOD_ANNOUNCE: + case METHOD_SET_PARAMETER: + onUnsupportedResponseReceived(response); + break; + case METHOD_UNSET: + default: + throw new IllegalStateException(); + } + } catch (ParserException e) { + dispatchRtspError(new RtspPlaybackException(e)); + } + } + + // Response handlers must only be called only on 200 (OK) responses. + + public void onOptionsResponseReceived(RtspOptionsResponse response) { + if (keepAliveMonitor != null) { + // Ignores the OPTIONS requests that are sent to keep RTSP connection alive. + return; + } + + if (serverSupportsDescribe(response.supportedMethods)) { + messageSender.sendDescribeRequest(uri, sessionId); + } else { + sessionInfoListener.onSessionTimelineRequestFailed( + "DESCRIBE not supported.", /* cause= */ null); + } + } + + public void onDescribeResponseReceived(RtspDescribeResponse response) { + String sessionRangeAttributeString = + checkNotNull(response.sessionDescription.attributes.get(SessionDescription.ATTR_RANGE)); + + try { + sessionInfoListener.onSessionTimelineUpdated( + RtspSessionTiming.parseTiming(sessionRangeAttributeString), + buildTrackList(response.sessionDescription, uri)); + hasUpdatedTimelineAndTracks = true; + } catch (ParserException e) { + sessionInfoListener.onSessionTimelineRequestFailed("SDP format error.", /* cause= */ e); + } + } + + public void onSetupResponseReceived(RtspSetupResponse response) { + sessionId = response.sessionHeader.sessionId; + continueSetupRtspTrack(); + } + + public void onPlayResponseReceived(RtspPlayResponse response) { + if (keepAliveMonitor == null) { + keepAliveMonitor = new KeepAliveMonitor(DEFAULT_RTSP_KEEP_ALIVE_INTERVAL_MS); + keepAliveMonitor.start(); + } + + checkNotNull(playbackEventListener) + .onPlaybackStarted( + C.msToUs(response.sessionTiming.startTimeMs), response.trackTimingList); + pendingSeekPositionUs = C.TIME_UNSET; + } + + public void onPauseResponseReceived(RtspResponse response) { + if (pendingSeekPositionUs != C.TIME_UNSET) { + startPlayback(C.usToMs(pendingSeekPositionUs)); + } + } + + public void onGetParameterResponseReceived(RtspResponse response) { + // Do nothing. + } + + public void onTeardownResponseReceived(RtspResponse response) { + // Do nothing. + } + + public void onUnsupportedResponseReceived(RtspResponse response) { + // Do nothing. + } + + private void dispatchRtspError(Throwable error) { + RtspPlaybackException playbackException = + error instanceof RtspPlaybackException + ? (RtspPlaybackException) error + : new RtspPlaybackException(error); + + if (hasUpdatedTimelineAndTracks) { + // Playback event listener must be non-null after timeline has been updated. + checkNotNull(playbackEventListener).onPlaybackError(playbackException); + } else { + sessionInfoListener.onSessionTimelineRequestFailed(nullToEmpty(error.getMessage()), error); + } + } + } + + /** Sends periodic OPTIONS requests to keep RTSP connection alive. */ + private final class KeepAliveMonitor implements Runnable, Closeable { + + private final Handler keepAliveHandler; + private final long intervalMs; + private boolean isStarted; + + /** + * Creates a new instance. + * + *

Constructor must be invoked on the playback thread. + * + * @param intervalMs The time between consecutive RTSP keep-alive requests, in milliseconds. + */ + public KeepAliveMonitor(long intervalMs) { + this.intervalMs = intervalMs; + keepAliveHandler = Util.createHandlerForCurrentLooper(); + } + + /** Starts Keep-alive. */ + public void start() { + if (isStarted) { + return; + } + + isStarted = true; + keepAliveHandler.postDelayed(this, intervalMs); + } + + @Override + public void run() { + messageSender.sendOptionsRequest(uri, sessionId); + keepAliveHandler.postDelayed(this, intervalMs); + } + + @Override + public void close() { + isStarted = false; + keepAliveHandler.removeCallbacks(this); + } + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspMediaPeriod.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspMediaPeriod.java new file mode 100644 index 0000000000..d46892e71d --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspMediaPeriod.java @@ -0,0 +1,658 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; +import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; +import static java.lang.Math.min; + +import android.net.Uri; +import android.os.Handler; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.FormatHolder; +import com.google.android.exoplayer2.SeekParameters; +import com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.SeekMap; +import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.offline.StreamKey; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.SampleQueue; +import com.google.android.exoplayer2.source.SampleQueue.UpstreamFormatChangedListener; +import com.google.android.exoplayer2.source.SampleStream; +import com.google.android.exoplayer2.source.SampleStream.ReadDataResult; +import com.google.android.exoplayer2.source.SampleStream.ReadFlags; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.source.rtsp.RtspClient.PlaybackEventListener; +import com.google.android.exoplayer2.source.rtsp.RtspMediaSource.RtspPlaybackException; +import com.google.android.exoplayer2.source.rtsp.rtp.RtpDataLoadable; +import com.google.android.exoplayer2.trackselection.ExoTrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.Loader; +import com.google.android.exoplayer2.upstream.Loader.Loadable; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** A {@link MediaPeriod} that loads an RTSP stream. */ +public final class RtspMediaPeriod implements MediaPeriod { + + private static final String TAG = "RtspMediaPeriod"; + private final Allocator allocator; + private final Handler handler; + + private final InternalListener internalListener; + private final RtspClient rtspClient; + private final List rtspLoaderWrappers; + private final List selectedLoadInfos; + + private @MonotonicNonNull Callback callback; + private @MonotonicNonNull ImmutableList trackGroups; + @Nullable private IOException preparationError; + @Nullable private RtspPlaybackException playbackException; + + private long pendingSeekPositionUs; + private boolean loadingFinished; + private boolean released; + private boolean prepared; + private boolean trackSelected; + + /** + * Creates an RTSP media period. + * + * @param allocator An {@link Allocator} from which to obtain media buffer allocations. + * @param rtspTracks A list of tracks in an RTSP playback session. + * @param rtspClient The {@link RtspClient} for the current RTSP playback. + */ + public RtspMediaPeriod( + Allocator allocator, List rtspTracks, RtspClient rtspClient) { + this.allocator = allocator; + handler = Util.createHandlerForCurrentLooper(); + + internalListener = new InternalListener(); + rtspLoaderWrappers = new ArrayList<>(rtspTracks.size()); + this.rtspClient = rtspClient; + this.rtspClient.setPlaybackEventListener(internalListener); + + for (int i = 0; i < rtspTracks.size(); i++) { + RtspMediaTrack rtspMediaTrack = rtspTracks.get(i); + rtspLoaderWrappers.add(new RtspLoaderWrapper(rtspMediaTrack, /* trackId= */ i)); + } + selectedLoadInfos = new ArrayList<>(rtspTracks.size()); + pendingSeekPositionUs = C.TIME_UNSET; + } + + /** Releases the {@link RtspMediaPeriod}. */ + public void release() { + for (int i = 0; i < rtspLoaderWrappers.size(); i++) { + rtspLoaderWrappers.get(i).release(); + } + released = true; + } + + @Override + public void prepare(Callback callback, long positionUs) { + this.callback = callback; + + for (int i = 0; i < rtspLoaderWrappers.size(); i++) { + rtspLoaderWrappers.get(i).startLoading(); + } + } + + @Override + public void maybeThrowPrepareError() throws IOException { + if (preparationError != null) { + throw preparationError; + } + } + + @Override + public TrackGroupArray getTrackGroups() { + checkState(prepared); + return new TrackGroupArray(checkNotNull(trackGroups).toArray(new TrackGroup[0])); + } + + @Override + public ImmutableList getStreamKeys(List trackSelections) { + return ImmutableList.of(); + } + + @Override + public long selectTracks( + @NullableType ExoTrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + + // Deselect old tracks. + // Input array streams contains the streams selected in the previous track selection. + for (int i = 0; i < selections.length; i++) { + if (streams[i] != null && (selections[i] == null || !mayRetainStreamFlags[i])) { + streams[i] = null; + } + } + + // Select new tracks. + selectedLoadInfos.clear(); + for (int i = 0; i < selections.length; i++) { + TrackSelection selection = selections[i]; + if (selection == null) { + continue; + } + + TrackGroup trackGroup = selection.getTrackGroup(); + int trackGroupIndex = checkNotNull(trackGroups).indexOf(trackGroup); + selectedLoadInfos.add(checkNotNull(rtspLoaderWrappers.get(trackGroupIndex)).loadInfo); + + // Find the sampleStreamWrapper that contains this track group. + if (trackGroups.contains(trackGroup)) { + if (streams[i] == null) { + streams[i] = new SampleStreamImpl(trackGroupIndex); + // Update flag for newly created SampleStream. + streamResetFlags[i] = true; + } + } + } + + // Cancel non-selected loadables. + for (int i = 0; i < rtspLoaderWrappers.size(); i++) { + RtspLoaderWrapper loadControl = rtspLoaderWrappers.get(i); + if (!selectedLoadInfos.contains(loadControl.loadInfo)) { + loadControl.cancelLoad(); + } + } + + trackSelected = true; + maybeSetupTracks(); + return positionUs; + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) { + if (isSeekPending()) { + return; + } + + for (int i = 0; i < rtspLoaderWrappers.size(); i++) { + RtspLoaderWrapper loaderWrapper = rtspLoaderWrappers.get(i); + if (!loaderWrapper.canceled) { + loaderWrapper.sampleQueue.discardTo(positionUs, toKeyframe, /* stopAtReadPosition= */ true); + } + } + } + + @Override + public long readDiscontinuity() { + return C.TIME_UNSET; + } + + @Override + public long seekToUs(long positionUs) { + if (isSeekPending()) { + // TODO(internal b/172331505) Allow seek when a seek is pending. + // Does not allow another seek if a seek is pending. + return pendingSeekPositionUs; + } + + if (seekInsideBufferUs(positionUs)) { + return positionUs; + } + + pendingSeekPositionUs = positionUs; + rtspClient.seekToUs(positionUs); + for (int i = 0; i < rtspLoaderWrappers.size(); i++) { + rtspLoaderWrappers.get(i).seekTo(positionUs); + } + return positionUs; + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + return positionUs; + } + + @Override + public long getBufferedPositionUs() { + if (loadingFinished || rtspLoaderWrappers.isEmpty()) { + return C.TIME_END_OF_SOURCE; + } + + if (isSeekPending()) { + return pendingSeekPositionUs; + } + + long bufferedPositionUs = rtspLoaderWrappers.get(0).sampleQueue.getLargestQueuedTimestampUs(); + for (int i = 1; i < rtspLoaderWrappers.size(); i++) { + bufferedPositionUs = + min( + bufferedPositionUs, + checkNotNull(rtspLoaderWrappers.get(i)).sampleQueue.getLargestQueuedTimestampUs()); + } + return bufferedPositionUs; + } + + @Override + public long getNextLoadPositionUs() { + return getBufferedPositionUs(); + } + + @Override + public boolean continueLoading(long positionUs) { + return isLoading(); + } + + @Override + public boolean isLoading() { + return !loadingFinished; + } + + @Override + public void reevaluateBuffer(long positionUs) { + // Do nothing. + } + + // SampleStream methods. + + /* package */ boolean isReady(int trackGroupIndex) { + return rtspLoaderWrappers.get(trackGroupIndex).isSampleQueueReady(); + } + + @ReadDataResult + /* package */ int readData( + int sampleQueueIndex, + FormatHolder formatHolder, + DecoderInputBuffer buffer, + @ReadFlags int readFlags) { + return rtspLoaderWrappers.get(sampleQueueIndex).read(formatHolder, buffer, readFlags); + } + + // Internal methods. + + @Nullable + private RtpDataLoadable getLoadableByTrackUri(Uri trackUri) { + for (int i = 0; i < rtspLoaderWrappers.size(); i++) { + RtpLoadInfo loadInfo = rtspLoaderWrappers.get(i).loadInfo; + if (loadInfo.getTrackUri().equals(trackUri)) { + return loadInfo.loadable; + } + } + return null; + } + + private boolean isSeekPending() { + return pendingSeekPositionUs != C.TIME_UNSET; + } + + private void maybeFinishPrepare() { + if (released || prepared) { + return; + } + + // Make sure all sample queues have got format assigned. + for (int i = 0; i < rtspLoaderWrappers.size(); i++) { + if (rtspLoaderWrappers.get(i).sampleQueue.getUpstreamFormat() == null) { + return; + } + } + + prepared = true; + trackGroups = buildTrackGroups(ImmutableList.copyOf(rtspLoaderWrappers)); + checkNotNull(callback).onPrepared(/* mediaPeriod= */ this); + } + + /** + * Attempts to seek to the specified position within the sample queues. + * + * @param positionUs The seek position in microseconds. + * @return Whether the in-buffer seek was successful for all loading RTSP tracks. + */ + private boolean seekInsideBufferUs(long positionUs) { + for (int i = 0; i < rtspLoaderWrappers.size(); i++) { + SampleQueue sampleQueue = rtspLoaderWrappers.get(i).sampleQueue; + if (!sampleQueue.seekTo(positionUs, /* allowTimeBeyondBuffer= */ false)) { + return false; + } + } + return true; + } + + private void maybeSetupTracks() { + boolean transportReady = true; + for (int i = 0; i < selectedLoadInfos.size(); i++) { + transportReady &= selectedLoadInfos.get(i).isTransportReady(); + } + + if (transportReady && trackSelected) { + rtspClient.setupSelectedTracks(selectedLoadInfos); + } + } + + private static ImmutableList buildTrackGroups( + ImmutableList rtspLoaderWrappers) { + ImmutableList.Builder listBuilder = new ImmutableList.Builder<>(); + SampleQueue sampleQueue; + for (int i = 0; i < rtspLoaderWrappers.size(); i++) { + sampleQueue = rtspLoaderWrappers.get(i).sampleQueue; + listBuilder.add(new TrackGroup(checkNotNull(sampleQueue.getUpstreamFormat()))); + } + return listBuilder.build(); + } + + private final class InternalListener + implements ExtractorOutput, + Loader.Callback, + UpstreamFormatChangedListener, + PlaybackEventListener { + + // ExtractorOutput implementation. + + @Override + public TrackOutput track(int id, int type) { + return checkNotNull(rtspLoaderWrappers.get(id)).sampleQueue; + } + + @Override + public void endTracks() { + // TODO(b/172331505) Implement this method. + } + + @Override + public void seekMap(SeekMap seekMap) { + // TODO(b/172331505) Implement this method. + } + + // Loadable.Callback implementation. + + @Override + public void onLoadCompleted( + RtpDataLoadable loadable, long elapsedRealtimeMs, long loadDurationMs) {} + + @Override + public void onLoadCanceled( + RtpDataLoadable loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released) {} + + @Override + public Loader.LoadErrorAction onLoadError( + RtpDataLoadable loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + /* TODO(b/172331505) Sort out the retry policy. + Three cases for IOException: + - Socket open failure for RTP or RTCP. + - RETRY for the RTCP open failure. + - ExtractorInput read IOException (socket timeout, etc) + - Keep retrying unless playback is stopped. + - RtpPayloadReader consume ParserException (mal-formatted RTP packet) + - Don't retry? (if a packet is distorted on the fly, the packet is likely discarded by the + system, i.e. the server's sent a mal-formatted packet). + */ + if (!prepared) { + preparationError = error; + } else { + if (error.getCause() instanceof SocketTimeoutException) { + handleSocketTimeout(loadable); + } else { + playbackException = + new RtspPlaybackException( + /* message= */ loadable.rtspMediaTrack.uri.toString(), error); + } + } + return Loader.DONT_RETRY; + } + + // SampleQueue.UpstreamFormatChangedListener implementation. + + @Override + public void onUpstreamFormatChanged(Format format) { + handler.post(RtspMediaPeriod.this::maybeFinishPrepare); + } + + // RtspClient.PlaybackEventListener implementation. + + @Override + public void onRtspSetupCompleted() { + rtspClient.startPlayback(/* offsetMs= */ 0); + } + + @Override + public void onPlaybackStarted( + long startPositionUs, ImmutableList trackTimingList) { + // Validate that the trackTimingList contains timings for the selected tracks. + ArrayList trackUrisWithTiming = new ArrayList<>(trackTimingList.size()); + for (int i = 0; i < trackTimingList.size(); i++) { + trackUrisWithTiming.add(trackTimingList.get(i).uri); + } + for (int i = 0; i < selectedLoadInfos.size(); i++) { + RtpLoadInfo loadInfo = selectedLoadInfos.get(i); + if (!trackUrisWithTiming.contains(loadInfo.getTrackUri())) { + playbackException = + new RtspPlaybackException( + "Server did not provide timing for track " + loadInfo.getTrackUri()); + return; + } + } + + for (int i = 0; i < trackTimingList.size(); i++) { + RtspTrackTiming trackTiming = trackTimingList.get(i); + @Nullable RtpDataLoadable dataLoadable = getLoadableByTrackUri(trackTiming.uri); + if (dataLoadable == null) { + continue; + } + + dataLoadable.setTimestamp(trackTiming.rtpTimestamp); + dataLoadable.setSequenceNumber(trackTiming.sequenceNumber); + + if (isSeekPending()) { + dataLoadable.seekToUs(startPositionUs, trackTiming.rtpTimestamp); + } + } + + if (isSeekPending()) { + pendingSeekPositionUs = C.TIME_UNSET; + } + } + + @Override + public void onPlaybackError(RtspPlaybackException error) { + playbackException = error; + } + + /** Handles the {@link Loadable} whose {@link DataSource} timed out. */ + private void handleSocketTimeout(RtpDataLoadable loadable) { + // TODO(b/172331505) Allow for retry when loading is not ending. + if (getBufferedPositionUs() == Long.MIN_VALUE) { + // Raise exception if no sample has been received so far. + playbackException = new RtspPlaybackException("Possible dropped UDP connection."); + return; + } + + for (int i = 0; i < rtspLoaderWrappers.size(); i++) { + RtspLoaderWrapper loaderWrapper = rtspLoaderWrappers.get(i); + if (loaderWrapper.loadInfo.loadable == loadable) { + loaderWrapper.cancelLoad(); + return; + } + } + playbackException = new RtspPlaybackException("Unknown loadable timed out."); + } + } + + private final class SampleStreamImpl implements SampleStream { + private final int track; + + public SampleStreamImpl(int track) { + this.track = track; + } + + @Override + public boolean isReady() { + return RtspMediaPeriod.this.isReady(track); + } + + @Override + public void maybeThrowError() throws RtspPlaybackException { + if (playbackException != null) { + throw playbackException; + } + } + + @Override + public int readData( + FormatHolder formatHolder, DecoderInputBuffer buffer, @ReadFlags int readFlags) { + return RtspMediaPeriod.this.readData(track, formatHolder, buffer, readFlags); + } + + @Override + public int skipData(long positionUs) { + return 0; + } + } + + /** Manages the loading of an RTSP track. */ + private final class RtspLoaderWrapper { + /** The {@link RtpLoadInfo} of the RTSP track to load. */ + public final RtpLoadInfo loadInfo; + + private final Loader loader; + private final SampleQueue sampleQueue; + private boolean canceled; + private boolean released; + + /** + * Creates a new instance. + * + *

Instances must be {@link #release() released} after loadings conclude. + */ + public RtspLoaderWrapper(RtspMediaTrack mediaTrack, int trackId) { + loadInfo = new RtpLoadInfo(mediaTrack, trackId); + loader = new Loader("ExoPlayer:RtspMediaPeriod:RtspDataLoader " + trackId); + sampleQueue = SampleQueue.createWithoutDrm(allocator); + sampleQueue.setUpstreamFormatChangeListener(internalListener); + } + + /** Starts loading. */ + public void startLoading() { + loader.startLoading( + loadInfo.loadable, /* callback= */ internalListener, /* defaultMinRetryCount= */ 0); + } + + public boolean isSampleQueueReady() { + return sampleQueue.isReady(/* loadingFinished= */ canceled); + } + + @ReadDataResult + public int read( + FormatHolder formatHolder, DecoderInputBuffer buffer, @ReadFlags int readFlags) { + return sampleQueue.read(formatHolder, buffer, readFlags, /* loadingFinished= */ canceled); + } + + /** Cancels loading. */ + public void cancelLoad() { + if (canceled) { + return; + } + loadInfo.loadable.cancelLoad(); + canceled = true; + + // Update loadingFinished every time loading is canceled. + for (int i = 0; i < rtspLoaderWrappers.size(); i++) { + loadingFinished &= rtspLoaderWrappers.get(i).canceled; + } + } + + /** Resets the {@link Loadable} and {@link SampleQueue} to prepare for an RTSP seek. */ + public void seekTo(long positionUs) { + loadInfo.loadable.resetForSeek(); + sampleQueue.reset(); + sampleQueue.setStartTimeUs(positionUs); + } + + /** Releases the instance. */ + public void release() { + if (released) { + return; + } + loader.release(); + sampleQueue.release(); + released = true; + } + } + + /** Groups the info needed for loading one RTSP track in RTP. */ + /* package */ final class RtpLoadInfo { + /** The {@link RtspMediaTrack}. */ + public final RtspMediaTrack mediaTrack; + + private final RtpDataLoadable loadable; + + @Nullable private String transport; + + /** Creates a new instance. */ + public RtpLoadInfo(RtspMediaTrack mediaTrack, int trackId) { + this.mediaTrack = mediaTrack; + + RtpDataLoadable.EventListener transportEventListener = + (transport) -> { + this.transport = transport; + maybeSetupTracks(); + }; + this.loadable = + new RtpDataLoadable( + trackId, + mediaTrack, + /* eventListener= */ transportEventListener, + /* output= */ internalListener); + } + + /** + * Returns whether RTP transport is ready. Call {@link #getTransport()} only after transport is + * ready. + */ + public boolean isTransportReady() { + return transport != null; + } + + /** + * Gets the transport string for RTP loading. + * + * @throws IllegalStateException When transport for this RTP stream is not set. + */ + public String getTransport() { + checkStateNotNull(transport); + return transport; + } + + /** Gets the {@link Uri} for the loading RTSP track. */ + public Uri getTrackUri() { + return loadable.rtspMediaTrack.uri; + } + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspMediaSource.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspMediaSource.java new file mode 100644 index 0000000000..888eb40e59 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspMediaSource.java @@ -0,0 +1,209 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp; + +import static com.google.android.exoplayer2.ExoPlayerLibraryInfo.VERSION_SLASHY; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; + +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.drm.DrmSessionManager; +import com.google.android.exoplayer2.drm.DrmSessionManagerProvider; +import com.google.android.exoplayer2.source.BaseMediaSource; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.MediaSourceFactory; +import com.google.android.exoplayer2.source.SinglePeriodTimeline; +import com.google.android.exoplayer2.source.rtsp.RtspClient.SessionInfoListener; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import java.io.IOException; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** An Rtsp {@link MediaSource} */ +public final class RtspMediaSource extends BaseMediaSource { + + /** + * Factory for {@link RtspMediaSource} + * + *

This factory doesn't support the following methods from {@link MediaSourceFactory}: + * + *

    + *
  • {@link #setDrmSessionManagerProvider(DrmSessionManagerProvider)} + *
  • {@link #setDrmSessionManager(DrmSessionManager)} + *
  • {@link #setDrmHttpDataSourceFactory(HttpDataSource.Factory)} + *
  • {@link #setDrmUserAgent(String)} + *
  • {@link #setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy)} + *
+ */ + public static final class Factory implements MediaSourceFactory { + + /** @deprecated Not supported. */ + @Deprecated + @Override + public Factory setDrmSessionManagerProvider( + @Nullable DrmSessionManagerProvider drmSessionManager) { + return this; + } + + /** @deprecated Not supported. */ + @Deprecated + @Override + public Factory setDrmSessionManager(@Nullable DrmSessionManager drmSessionManager) { + return this; + } + + /** @deprecated Not supported. */ + @Deprecated + @Override + public Factory setDrmHttpDataSourceFactory( + @Nullable HttpDataSource.Factory drmHttpDataSourceFactory) { + return this; + } + + /** @deprecated Not supported. */ + @Deprecated + @Override + public Factory setDrmUserAgent(@Nullable String userAgent) { + return this; + } + + /** @deprecated Not supported. */ + @Deprecated + @Override + public Factory setLoadErrorHandlingPolicy( + @Nullable LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + return this; + } + + @Override + public int[] getSupportedTypes() { + return new int[] {C.TYPE_RTSP}; + } + + /** + * Returns a new {@link RtspMediaSource} using the current parameters. + * + * @param mediaItem The {@link MediaItem}. + * @return The new {@link RtspMediaSource}. + * @throws NullPointerException if {@link MediaItem#playbackProperties} is {@code null}. + */ + @Override + public RtspMediaSource createMediaSource(MediaItem mediaItem) { + checkNotNull(mediaItem.playbackProperties); + return new RtspMediaSource(mediaItem); + } + } + + /** Thrown when an exception or error is encountered during loading an RTSP stream. */ + public static final class RtspPlaybackException extends IOException { + public RtspPlaybackException(String message) { + super(message); + } + + public RtspPlaybackException(Throwable e) { + super(e); + } + + public RtspPlaybackException(String message, Throwable e) { + super(message, e); + } + } + + private final MediaItem mediaItem; + private @MonotonicNonNull RtspClient rtspClient; + + @Nullable private ImmutableList rtspMediaTracks; + @Nullable private IOException sourcePrepareException; + + private RtspMediaSource(MediaItem mediaItem) { + this.mediaItem = mediaItem; + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + checkNotNull(mediaItem.playbackProperties); + try { + rtspClient = + new RtspClient( + new SessionInfoListenerImpl(), + /* userAgent= */ VERSION_SLASHY, + mediaItem.playbackProperties.uri); + rtspClient.start(); + } catch (IOException e) { + sourcePrepareException = new RtspPlaybackException("RtspClient not opened.", e); + } + } + + @Override + protected void releaseSourceInternal() { + Util.closeQuietly(rtspClient); + } + + @Override + public MediaItem getMediaItem() { + return mediaItem; + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + if (sourcePrepareException != null) { + throw sourcePrepareException; + } + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + return new RtspMediaPeriod(allocator, checkNotNull(rtspMediaTracks), checkNotNull(rtspClient)); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + ((RtspMediaPeriod) mediaPeriod).release(); + } + + private final class SessionInfoListenerImpl implements SessionInfoListener { + @Override + public void onSessionTimelineUpdated( + RtspSessionTiming timing, ImmutableList tracks) { + rtspMediaTracks = tracks; + refreshSourceInfo( + new SinglePeriodTimeline( + /* durationUs= */ C.msToUs(timing.getDurationMs()), + /* isSeekable= */ !timing.isLive(), + /* isDynamic= */ false, + /* useLiveConfiguration= */ timing.isLive(), + /* manifest= */ null, + mediaItem)); + } + + @Override + public void onSessionTimelineRequestFailed(String message, @Nullable Throwable cause) { + if (cause == null) { + sourcePrepareException = new RtspPlaybackException(message); + } else { + sourcePrepareException = new RtspPlaybackException(message, castNonNull(cause)); + } + } + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspMediaTrack.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspMediaTrack.java new file mode 100644 index 0000000000..59a105fda0 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspMediaTrack.java @@ -0,0 +1,218 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp; + +import static com.google.android.exoplayer2.source.rtsp.rtp.RtpPayloadFormat.getMimeTypeFromRtpMediaType; +import static com.google.android.exoplayer2.source.rtsp.sdp.MediaDescription.MEDIA_TYPE_AUDIO; +import static com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription.ATTR_CONTROL; +import static com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription.ATTR_RTPMAP; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.NalUnitUtil.NAL_START_CODE; +import static com.google.android.exoplayer2.util.Util.castNonNull; + +import android.net.Uri; +import android.util.Base64; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.audio.AacUtil; +import com.google.android.exoplayer2.source.rtsp.rtp.RtpPayloadFormat; +import com.google.android.exoplayer2.source.rtsp.sdp.MediaDescription; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.NalUnitUtil; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +/** Represents a media track in an RTSP playback. */ +public final class RtspMediaTrack { + // Format specific parameter names. + private static final String PARAMETER_PROFILE_LEVEL_ID = "profile-level-id"; + private static final String PARAMETER_SPROP_PARAMS = "sprop-parameter-sets"; + /** Prefix for the RFC6381 codecs string for AAC formats. */ + private static final String AAC_CODECS_PREFIX = "mp4a.40."; + /** Prefix for the RFC6381 codecs string for AVC formats. */ + private static final String H264_CODECS_PREFIX = "avc1."; + + /** The track's associated {@link RtpPayloadFormat}. */ + public final RtpPayloadFormat payloadFormat; + /** The track's URI. */ + public final Uri uri; + + /** + * Creates a new instance from a {@link MediaDescription}. + * + * @param mediaDescription The {@link MediaDescription} of this track. + * @param sessionUri The {@link Uri} of the RTSP playback session. + */ + public RtspMediaTrack(MediaDescription mediaDescription, Uri sessionUri) { + checkArgument(mediaDescription.attributes.containsKey(ATTR_CONTROL)); + payloadFormat = generatePayloadFormat(mediaDescription); + uri = + sessionUri + .buildUpon() + .appendEncodedPath(castNonNull(mediaDescription.attributes.get(ATTR_CONTROL))) + .build(); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RtspMediaTrack that = (RtspMediaTrack) o; + return payloadFormat.equals(that.payloadFormat) && uri.equals(that.uri); + } + + @Override + public int hashCode() { + int result = 7; + result = 31 * result + payloadFormat.hashCode(); + result = 31 * result + uri.hashCode(); + return result; + } + + @VisibleForTesting + /* package */ static RtpPayloadFormat generatePayloadFormat(MediaDescription mediaDescription) { + Format.Builder formatBuilder = new Format.Builder(); + + if (mediaDescription.bitrate > 0) { + formatBuilder.setAverageBitrate(mediaDescription.bitrate); + } + + // rtpmap is mandatory in an RTSP session with dynamic payload types (RFC2326 Section C.1.3). + checkArgument(mediaDescription.attributes.containsKey(ATTR_RTPMAP)); + String rtpmapAttribute = castNonNull(mediaDescription.attributes.get(ATTR_RTPMAP)); + + // rtpmap string format: RFC2327 Page 22. + String[] rtpmap = Util.split(rtpmapAttribute, " "); + checkArgument(rtpmap.length == 2); + int rtpPayloadType = mediaDescription.rtpMapAttribute.payloadType; + + String mimeType = getMimeTypeFromRtpMediaType(mediaDescription.rtpMapAttribute.mediaEncoding); + formatBuilder.setSampleMimeType(mimeType); + + int clockRate = mediaDescription.rtpMapAttribute.clockRate; + int channelCount = C.INDEX_UNSET; + if (MEDIA_TYPE_AUDIO.equals(mediaDescription.mediaType)) { + channelCount = + inferChannelCount(mediaDescription.rtpMapAttribute.encodingParameters, mimeType); + formatBuilder.setSampleRate(clockRate).setChannelCount(channelCount); + } + + ImmutableMap fmtpParameters = mediaDescription.getFmtpParametersAsMap(); + switch (mimeType) { + case MimeTypes.AUDIO_AAC: + checkArgument(channelCount != C.INDEX_UNSET); + checkArgument(!fmtpParameters.isEmpty()); + processAacFmtpAttribute(formatBuilder, fmtpParameters, channelCount, clockRate); + break; + case MimeTypes.VIDEO_H264: + checkArgument(!fmtpParameters.isEmpty()); + processH264FmtpAttribute(formatBuilder, fmtpParameters); + break; + case MimeTypes.AUDIO_AC3: + // AC3 does not require a FMTP attribute. Fall through. + default: + // Do nothing. + } + + checkArgument(clockRate > 0); + // Checks if payload type is "dynamic" as defined in RFC3551 Section 3. + checkArgument(rtpPayloadType >= 96); + return new RtpPayloadFormat(formatBuilder.build(), rtpPayloadType, clockRate, fmtpParameters); + } + + private static int inferChannelCount(int encodingParameter, String mimeType) { + if (encodingParameter != C.INDEX_UNSET) { + // The encoding parameter specifies the number of channels in audio streams when + // present. If omitted, the number of channels is one. This parameter has no significance in + // video streams. (RFC2327 Page 22). + return encodingParameter; + } + + if (mimeType.equals(MimeTypes.AUDIO_AC3)) { + // If RTPMAP attribute does not include channel count for AC3, default to 6. + return 6; + } + + return 1; + } + + private static void processAacFmtpAttribute( + Format.Builder formatBuilder, + ImmutableMap fmtpAttributes, + int channelCount, + int sampleRate) { + checkArgument(fmtpAttributes.containsKey(PARAMETER_PROFILE_LEVEL_ID)); + String profileLevel = checkNotNull(fmtpAttributes.get(PARAMETER_PROFILE_LEVEL_ID)); + formatBuilder.setCodecs(AAC_CODECS_PREFIX + profileLevel); + formatBuilder.setInitializationData( + ImmutableList.of( + // Clock rate equals to sample rate in RTP. + AacUtil.buildAacLcAudioSpecificConfig(sampleRate, channelCount))); + } + + private static void processH264FmtpAttribute( + Format.Builder formatBuilder, ImmutableMap fmtpAttributes) { + checkArgument(fmtpAttributes.containsKey(PARAMETER_PROFILE_LEVEL_ID)); + String profileLevel = checkNotNull(fmtpAttributes.get(PARAMETER_PROFILE_LEVEL_ID)); + formatBuilder.setCodecs(H264_CODECS_PREFIX + profileLevel); + + checkArgument(fmtpAttributes.containsKey(PARAMETER_SPROP_PARAMS)); + String spropParameterSets = checkNotNull(fmtpAttributes.get(PARAMETER_SPROP_PARAMS)); + String[] parameterSets = Util.split(spropParameterSets, ","); + checkArgument(parameterSets.length == 2); + ImmutableList initializationData = + ImmutableList.of( + getH264InitializationDataFromParameterSet(parameterSets[0]), + getH264InitializationDataFromParameterSet(parameterSets[1])); + formatBuilder.setInitializationData(initializationData); + + // Process SPS (Sequence Parameter Set). + byte[] spsNalDataWithStartCode = initializationData.get(0); + NalUnitUtil.SpsData spsData = + NalUnitUtil.parseSpsNalUnit( + spsNalDataWithStartCode, NAL_START_CODE.length, spsNalDataWithStartCode.length); + formatBuilder.setPixelWidthHeightRatio(spsData.pixelWidthAspectRatio); + formatBuilder.setHeight(spsData.height); + formatBuilder.setWidth(spsData.width); + } + + private static byte[] getH264InitializationDataFromParameterSet(String parameterSet) { + byte[] decodedParameterNalData = Base64.decode(parameterSet, Base64.DEFAULT); + byte[] decodedParameterNalUnit = + new byte[decodedParameterNalData.length + NAL_START_CODE.length]; + System.arraycopy( + NAL_START_CODE, + /* srcPos= */ 0, + decodedParameterNalUnit, + /* destPos= */ 0, + NAL_START_CODE.length); + System.arraycopy( + decodedParameterNalData, + /* srcPos= */ 0, + decodedParameterNalUnit, + /* destPos= */ NAL_START_CODE.length, + decodedParameterNalData.length); + return decodedParameterNalUnit; + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspSessionTiming.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspSessionTiming.java new file mode 100644 index 0000000000..50a0f272c6 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspSessionTiming.java @@ -0,0 +1,105 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp; + +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.util.Util; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Represent the timing (RTSP Normal Playback Time format) of an RTSP session. + * + *

Currently only NPT is supported. See RFC2326 Section 3.6 for detail of NPT. + */ +public final class RtspSessionTiming { + /** The default session timing starting from 0.000 and indefinite length. */ + public static final RtspSessionTiming DEFAULT = + new RtspSessionTiming(/* startTimeMs= */ 0, /* stopTimeMs= */ C.TIME_UNSET); + + // We only support npt=xxx-[xxx], but not npt=-xxx. See RFC2326 Section 3.6. + private static final Pattern NPT_RANGE_PATTERN = + Pattern.compile("npt=([.\\d]+|now)\\s?-\\s?([.\\d]+)?"); + private static final String START_TIMING_NTP_FORMAT = "npt=%.3f-"; + + private static final long LIVE_START_TIME = 0; + + /** Parses an SDP range attribute (RFC2326 Section 3.6). */ + public static RtspSessionTiming parseTiming(String sdpRangeAttribute) throws ParserException { + long startTimeMs; + long stopTimeMs; + Matcher matcher = NPT_RANGE_PATTERN.matcher(sdpRangeAttribute); + checkArgument(matcher.matches()); + + String startTimeString = checkNotNull(matcher.group(1)); + if (startTimeString.equals("now")) { + startTimeMs = LIVE_START_TIME; + } else { + startTimeMs = (long) (Float.parseFloat(startTimeString) * C.MILLIS_PER_SECOND); + } + + @Nullable String stopTimeString = matcher.group(2); + if (stopTimeString != null) { + try { + stopTimeMs = (long) (Float.parseFloat(stopTimeString) * C.MILLIS_PER_SECOND); + } catch (NumberFormatException e) { + throw new ParserException(e); + } + checkArgument(stopTimeMs > startTimeMs); + } else { + stopTimeMs = C.TIME_UNSET; + } + + return new RtspSessionTiming(startTimeMs, stopTimeMs); + } + + /** Gets a Range RTSP header for an RTSP PLAY request. */ + public static String getOffsetStartTimeTiming(long offsetStartTimeMs) { + double offsetStartTimeSec = (double) offsetStartTimeMs / C.MILLIS_PER_SECOND; + return Util.formatInvariant(START_TIMING_NTP_FORMAT, offsetStartTimeSec); + } + + /** + * The start time of this session, in milliseconds. When playing a live session, the start time is + * always zero. + */ + public final long startTimeMs; + /** + * The stop time of the session, in milliseconds, or {@link C#TIME_UNSET} when the stop time is + * not set, for example when playing a live session. + */ + public final long stopTimeMs; + + private RtspSessionTiming(long startTimeMs, long stopTimeMs) { + this.startTimeMs = startTimeMs; + this.stopTimeMs = stopTimeMs; + } + + /** Tests whether the timing is live. */ + public boolean isLive() { + return stopTimeMs == C.TIME_UNSET; + } + + /** Gets the session duration in milliseconds. */ + public long getDurationMs() { + return stopTimeMs - startTimeMs; + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspTrackTiming.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspTrackTiming.java new file mode 100644 index 0000000000..07c6ebf059 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspTrackTiming.java @@ -0,0 +1,114 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp; + +import android.net.Uri; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.source.rtsp.message.RtspHeaders; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; + +/** + * Represents an RTSP track's timing info, included as {@link RtspHeaders#RTP_INFO} in an RTSP PLAY + * response (RFC2326 Section 12.33). + * + *

The fields {@link #rtpTimestamp} and {@link #sequenceNumber} will not both be {@code null}. + */ +public final class RtspTrackTiming { + + /** + * Parses the RTP-Info header into a list of {@link RtspTrackTiming RtspTrackTimings}. + * + *

The syntax of the RTP-Info (RFC2326 Section 12.33): + * + *

+   *   RTP-Info        = "RTP-Info" ":" 1#stream-url 1*parameter
+   *   stream-url      = "url" "=" url
+   *   parameter       = ";" "seq" "=" 1*DIGIT
+   *                   | ";" "rtptime" "=" 1*DIGIT
+   * 
+ * + *

Examples from RFC2326: + * + *

+   *   RTP-Info:url=rtsp://foo.com/bar.file; seq=232433;rtptime=972948234
+   *   RTP-Info:url=rtsp://foo.com/bar.avi/streamid=0;seq=45102,
+   *            url=rtsp://foo.com/bar.avi/streamid=1;seq=30211
+   * 
+ * + * @param rtpInfoString The value of the RTP-Info header, with header name (RTP-Info) removed. + * @return A list of parsed {@link RtspTrackTiming}. + * @throws ParserException If parsing failed. + */ + public static ImmutableList parseTrackTiming(String rtpInfoString) + throws ParserException { + + ImmutableList.Builder listBuilder = new ImmutableList.Builder<>(); + for (String perTrackTimingString : Util.split(rtpInfoString, ",")) { + long rtpTime = C.TIME_UNSET; + int sequenceNumber = C.INDEX_UNSET; + @Nullable Uri uri = null; + + for (String attributePair : Util.split(perTrackTimingString, ";")) { + try { + String[] attributes = Util.splitAtFirst(attributePair, "="); + String attributeName = attributes[0]; + String attributeValue = attributes[1]; + + switch (attributeName) { + case "url": + uri = Uri.parse(attributeValue); + break; + case "seq": + sequenceNumber = Integer.parseInt(attributeValue); + break; + case "rtptime": + rtpTime = Long.parseLong(attributeValue); + break; + default: + throw new ParserException(); + } + } catch (Exception e) { + throw new ParserException(attributePair, e); + } + } + + if (uri == null + || uri.getScheme() == null // Checks if the URI is a URL. + || (sequenceNumber == C.INDEX_UNSET && rtpTime == C.TIME_UNSET)) { + throw new ParserException(perTrackTimingString); + } + + listBuilder.add(new RtspTrackTiming(rtpTime, sequenceNumber, uri)); + } + return listBuilder.build(); + } + + /** The timestamp of the next RTP packet, {@link C#TIME_UNSET} if not present. */ + public final long rtpTimestamp; + /** The sequence number of the next RTP packet, {@link C#INDEX_UNSET} if not present. */ + public final int sequenceNumber; + /** The {@link Uri} that identifies a matching {@link RtspMediaTrack}. */ + public final Uri uri; + + private RtspTrackTiming(long rtpTimestamp, int sequenceNumber, Uri uri) { + this.rtpTimestamp = rtpTimestamp; + this.sequenceNumber = sequenceNumber; + this.uri = uri; + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspDescribeResponse.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspDescribeResponse.java new file mode 100644 index 0000000000..631ebf7012 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspDescribeResponse.java @@ -0,0 +1,37 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.message; + +import com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription; + +/** Represents an RTSP DESCRIBE response. */ +public final class RtspDescribeResponse { + /** The response's status code. */ + public final int status; + /** The {@link SessionDescription} (see RFC2327) in the DESCRIBE response. */ + public final SessionDescription sessionDescription; + + /** + * Creates a new instance. + * + * @param status The response's status code. + * @param sessionDescription The {@link SessionDescription} in the DESCRIBE response. + */ + public RtspDescribeResponse(int status, SessionDescription sessionDescription) { + this.status = status; + this.sessionDescription = sessionDescription; + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspHeaders.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspHeaders.java new file mode 100644 index 0000000000..1eb3de1adc --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspHeaders.java @@ -0,0 +1,166 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.message; + +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Ascii; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * RTSP message headers. + * + *

{@link Builder} must be used to construct an instance. Use {@link #get} to query header values + * with case-insensitive header names. The extra spaces around header names and values are trimmed. + * Contrary to HTTP, RTSP does not allow ambiguous/arbitrary header names (RFC 2326 Section 12). + */ +public final class RtspHeaders { + + public static final String ACCEPT = "Accept"; + public static final String ALLOW = "Allow"; + public static final String AUTHORIZATION = "Authorization"; + public static final String BANDWIDTH = "Bandwidth"; + public static final String BLOCKSIZE = "Blocksize"; + public static final String CACHE_CONTROL = "Cache-Control"; + public static final String CONNECTION = "Connection"; + public static final String CONTENT_BASE = "Content-Base"; + public static final String CONTENT_ENCODING = "Content-Encoding"; + public static final String CONTENT_LANGUAGE = "Content-Language"; + public static final String CONTENT_LENGTH = "Content-Length"; + public static final String CONTENT_LOCATION = "Content-Location"; + public static final String CONTENT_TYPE = "Content-Type"; + public static final String CSEQ = "CSeq"; + public static final String DATE = "Date"; + public static final String EXPIRES = "Expires"; + public static final String PROXY_AUTHENTICATE = "Proxy-Authenticate"; + public static final String PROXY_REQUIRE = "Proxy-Require"; + public static final String PUBLIC = "Public"; + public static final String RANGE = "Range"; + public static final String RTP_INFO = "RTP-Info"; + public static final String RTCP_INTERVAL = "RTCP-Interval"; + public static final String SCALE = "Scale"; + public static final String SESSION = "Session"; + public static final String SPEED = "Speed"; + public static final String SUPPORTED = "Supported"; + public static final String TIMESTAMP = "Timestamp"; + public static final String TRANSPORT = "Transport"; + public static final String USER_AGENT = "User-Agent"; + public static final String VIA = "Via"; + public static final String WWW_AUTHENTICATE = "WWW-Authenticate"; + + /** Builds {@link RtspHeaders} instances. */ + public static final class Builder { + private final List namesAndValues; + + /** Creates a new instance. */ + public Builder() { + namesAndValues = new ArrayList<>(); + } + + /** + * Adds a header name and header value pair. + * + * @param headerName The name of the header. + * @param headerValue The value of the header. + * @return This builder. + */ + public Builder add(String headerName, String headerValue) { + namesAndValues.add(headerName.trim()); + namesAndValues.add(headerValue.trim()); + return this; + } + + /** + * Adds a list of headers. + * + * @param headers The list of headers, each item must following the format <headerName>: + * <headerValue> + * @return This builder. + */ + public Builder addAll(List headers) { + for (int i = 0; i < headers.size(); i++) { + String[] header = Util.splitAtFirst(headers.get(i), ":\\s?"); + if (header.length == 2) { + add(header[0], header[1]); + } + } + return this; + } + + /** + * Adds multiple headers in a map. + * + * @param headers The map of headers, where the keys are the header names and the values are the + * header values. + * @return This builder. + */ + public Builder addAll(Map headers) { + for (Map.Entry header : headers.entrySet()) { + add(header.getKey(), header.getValue()); + } + return this; + } + + /** + * Builds a new {@link RtspHeaders} instance. + * + * @return The newly built {@link RtspHeaders} instance. + */ + public RtspHeaders build() { + return new RtspHeaders(this); + } + } + + private final ImmutableList namesAndValues; + + /** + * Gets the headers as a map, where the keys are the header names and values are the header + * values. + * + * @return The headers as a map. The keys of the map have follows those that are used to build + * this {@link RtspHeaders} instance. + */ + public ImmutableMap asMap() { + Map headers = new LinkedHashMap<>(); + for (int i = 0; i < namesAndValues.size(); i += 2) { + headers.put(namesAndValues.get(i), namesAndValues.get(i + 1)); + } + return ImmutableMap.copyOf(headers); + } + + /** + * Returns a header value mapped to the argument, {@code null} if the header name is not recorded. + */ + @Nullable + public String get(String headerName) { + for (int i = namesAndValues.size() - 2; i >= 0; i -= 2) { + if (Ascii.equalsIgnoreCase(headerName, namesAndValues.get(i))) { + return namesAndValues.get(i + 1); + } + } + return null; + } + + private RtspHeaders(Builder builder) { + this.namesAndValues = ImmutableList.copyOf(builder.namesAndValues); + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspMessageChannel.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspMessageChannel.java new file mode 100644 index 0000000000..4d12d03ea1 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspMessageChannel.java @@ -0,0 +1,291 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.message; + +import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; + +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.util.Log; +import com.google.android.exoplayer2.upstream.Loader; +import com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; +import com.google.android.exoplayer2.upstream.Loader.Loadable; +import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Charsets; +import com.google.common.base.Joiner; +import java.io.BufferedReader; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.Socket; +import java.util.ArrayList; +import java.util.List; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** Sends and receives RTSP messages. */ +public final class RtspMessageChannel implements Closeable { + + private static final String TAG = "RtspMessageChannel"; + private static final boolean LOG_RTSP_MESSAGES = false; + + /** A listener for received RTSP messages and possible failures. */ + public interface MessageListener { + + /** + * Called when an RTSP message is received. + * + * @param message The non-empty list of received lines, with line terminators removed. + */ + void onRtspMessageReceived(List message); + + /** + * Called when failed to send an RTSP message. + * + * @param message The list of lines making up the RTSP message that is failed to send. + * @param e The thrown {@link Exception}. + */ + default void onSendingFailed(List message, Exception e) {} + + /** + * Called when failed to receive an RTSP message. + * + * @param e The thrown {@link Exception}. + */ + default void onReceivingFailed(Exception e) {} + } + + /** + * The IANA-registered default port for RTSP. See here + */ + public static final int DEFAULT_RTSP_PORT = 554; + + /** + * The handler for all {@code messageListener} interactions. Backed by the thread on which this + * class is constructed. + */ + private final Handler messageListenerHandler; + + private final MessageListener messageListener; + private final Loader receiverLoader; + private @MonotonicNonNull Sender sender; + private @MonotonicNonNull Socket socket; + + private boolean closed; + + /** + * Constructs a new instance. + * + *

The constructor must be called on a {@link Looper} thread. The thread is also where {@link + * MessageListener} events are sent. User must construct a socket for RTSP and call {@link + * #openSocket} to open the connection before being able to send and receive, and {@link #close} + * it when done. + * + *

Note: all method invocations must be made from the thread on which this class is created. + * + * @param messageListener The {@link MessageListener} to receive events. + */ + public RtspMessageChannel(MessageListener messageListener) { + this.messageListenerHandler = Util.createHandlerForCurrentLooper(); + this.messageListener = messageListener; + this.receiverLoader = new Loader("ExoPlayer:RtspMessageChannel:ReceiverLoader"); + } + + /** + * Opens the message channel to send and receive RTSP messages. + * + *

Note: If an {@link IOException} is thrown, callers must still call {@link #close()} to + * ensure that any partial effects of the invocation are cleaned up. + * + * @param socket An accepted {@link Socket}. + */ + public void openSocket(Socket socket) throws IOException { + this.socket = socket; + sender = new Sender(socket.getOutputStream()); + + receiverLoader.startLoading( + new Receiver(socket.getInputStream()), + new LoaderCallbackImpl(), + /* defaultMinRetryCount= */ 0); + } + + /** + * Closes the RTSP message channel. + * + *

The closed instance must not be re-opened again. The {@link MessageListener} will not + * receive further messages after closing. + * + * @throws IOException If an error occurs closing the message channel. + */ + @Override + public void close() throws IOException { + if (sender != null) { + sender.close(); + } + receiverLoader.release(); + + if (socket != null) { + socket.close(); + } + + messageListenerHandler.removeCallbacksAndMessages(/* token= */ null); + closed = true; + } + + /** + * Sends a serialized RTSP message. + * + * @param message The list of strings representing the serialized RTSP message. + */ + public void send(List message) { + checkStateNotNull(sender); + sender.send(message); + } + + private static void logMessage(List rtspMessage) { + if (LOG_RTSP_MESSAGES) { + Log.d(TAG, Joiner.on('\n').join(rtspMessage)); + } + } + + private final class Sender implements Closeable { + + private final OutputStream outputStream; + private final HandlerThread senderThread; + private final Handler senderThreadHandler; + + /** + * Creates a new instance. + * + * @param outputStream The {@link OutputStream} of the opened RTSP {@link Socket}, to which the + * request is sent. The caller needs to close the {@link OutputStream}. + */ + public Sender(OutputStream outputStream) { + this.outputStream = outputStream; + this.senderThread = new HandlerThread("ExoPlayer:RtspMessageChannel:Sender"); + this.senderThread.start(); + this.senderThreadHandler = new Handler(this.senderThread.getLooper()); + } + + /** + * Sends out RTSP messages that are in the forms of lists of strings. + * + *

If {@link Exception} is thrown while sending, the message {@link + * MessageListener#onSendingFailed} is dispatched to the thread that created the {@link + * RtspMessageChannel}. + * + * @param message The must of strings representing the serialized RTSP message. + */ + public void send(List message) { + logMessage(message); + byte[] data = RtspMessageUtil.convertMessageToByteArray(message); + senderThreadHandler.post( + () -> { + try { + outputStream.write(data); + } catch (Exception e) { + messageListenerHandler.post( + () -> { + if (!closed) { + messageListener.onSendingFailed(message, e); + } + }); + } + }); + } + + @Override + public void close() { + senderThreadHandler.post(senderThread::quit); + try { + // Waits until all the messages posted to the sender thread are handled. + senderThread.join(); + } catch (InterruptedException e) { + senderThread.interrupt(); + } + } + } + + /** A {@link Loadable} for receiving RTSP responses. */ + private final class Receiver implements Loadable { + private final BufferedReader inputStreamReader; + + private volatile boolean loadCanceled; + + /** + * Creates a new instance. + * + * @param inputStream The {@link InputStream} of the opened RTSP {@link Socket}, from which the + * {@link RtspResponse RtspResponses} are received. The caller needs to close the {@link + * InputStream}. + */ + public Receiver(InputStream inputStream) { + inputStreamReader = new BufferedReader(new InputStreamReader(inputStream, Charsets.UTF_8)); + } + + @Override + public void cancelLoad() { + loadCanceled = true; + } + + @Override + public void load() throws IOException { + List messageLines = new ArrayList<>(); + while (!loadCanceled) { + String line; + while (inputStreamReader.ready() && (line = inputStreamReader.readLine()) != null) { + messageLines.add(line); + } + + if (!messageLines.isEmpty()) { + List message = new ArrayList<>(messageLines); + logMessage(message); + messageListenerHandler.post( + () -> { + if (!closed) { + messageListener.onRtspMessageReceived(message); + } + }); + // Resets for the next response. + messageLines.clear(); + } + } + } + } + + private final class LoaderCallbackImpl implements Loader.Callback { + @Override + public void onLoadCompleted(Receiver loadable, long elapsedRealtimeMs, long loadDurationMs) {} + + @Override + public void onLoadCanceled( + Receiver loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released) {} + + @Override + public LoadErrorAction onLoadError( + Receiver loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + messageListener.onReceivingFailed(error); + return Loader.DONT_RETRY; + } + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspMessageUtil.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspMessageUtil.java new file mode 100644 index 0000000000..5bc7dd1378 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspMessageUtil.java @@ -0,0 +1,360 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.message; + +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_ANNOUNCE; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_DESCRIBE; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_GET_PARAMETER; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_OPTIONS; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_PAUSE; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_PLAY; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_PLAY_NOTIFY; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_RECORD; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_REDIRECT; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_SETUP; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_SET_PARAMETER; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_TEARDOWN; +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_UNSET; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import android.net.Uri; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Charsets; +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Utility methods for RTSP messages. */ +public 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; + } + } + + /** 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+) (.+)"); + + // Session header pattern, see RFC2326 Section 12.37. + private static final Pattern SESSION_HEADER_PATTERN = + Pattern.compile("(\\w+)(?:;\\s?timeout=(\\d+))?"); + + private static final String RTSP_VERSION = "RTSP/1.0"; + + /** + * Serializes an {@link RtspRequest} to an {@link ImmutableList} of strings. + * + * @param request The {@link RtspRequest}. + * @return A list of the lines of the {@link RtspRequest}, without line terminators (CRLF). + */ + public static ImmutableList serializeRequest(RtspRequest request) { + ImmutableList.Builder builder = new ImmutableList.Builder<>(); + // Request line. + builder.add( + Util.formatInvariant( + "%s %s %s", toMethodString(request.method), request.uri, RTSP_VERSION)); + ImmutableMap headers = request.headers.asMap(); + for (String headerName : headers.keySet()) { + builder.add( + Util.formatInvariant( + "%s: %s", headerName, checkNotNull(request.headers.get(headerName)))); + } + // Empty line after headers. + builder.add(""); + builder.add(request.messageBody); + return builder.build(); + } + + /** + * Serializes an {@link RtspResponse} to an {@link ImmutableList} of strings. + * + * @param response The {@link RtspResponse}. + * @return A list of the lines of the {@link RtspResponse}, without line terminators (CRLF). + */ + public static ImmutableList serializeResponse(RtspResponse response) { + ImmutableList.Builder builder = new ImmutableList.Builder<>(); + // Request line. + builder.add( + Util.formatInvariant( + "%s %s %s", RTSP_VERSION, response.status, getRtspStatusReasonPhrase(response.status))); + + ImmutableMap headers = response.headers.asMap(); + for (String headerName : headers.keySet()) { + builder.add( + Util.formatInvariant( + "%s: %s", headerName, checkNotNull(response.headers.get(headerName)))); + } + // 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 message) { + return Joiner.on("\r\n").join(message).getBytes(Charsets.UTF_8); + } + + /** 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(); + } + + /** Returns the corresponding String representation of the {@link RtspRequest.Method} argument. */ + public static String toMethodString(@RtspRequest.Method int method) { + switch (method) { + case RtspRequest.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(); + } + } + + @RtspRequest.Method + private static 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 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 headerLines = lines.subList(1, messageBodyOffset); + RtspHeaders headers = new RtspHeaders.Builder().addAll(headerLines).build(); + + String messageBody = Joiner.on("\r\n").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 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 headerLines = lines.subList(1, messageBodyOffset); + RtspHeaders headers = new RtspHeaders.Builder().addAll(headerLines).build(); + + String messageBody = Joiner.on("\r\n").join(lines.subList(messageBodyOffset + 1, lines.size())); + return new RtspRequest(requestUri, method, headers, messageBody); + } + + /** + * 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 parsePublicHeader(@Nullable String publicHeader) { + if (publicHeader == null) { + return ImmutableList.of(); + } + + ImmutableList.Builder 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}. + * + *

The format of the Session header is + * + *

+   * Session: session-id[;timeout=delta-seconds]
+   * 
+ * + * @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 new ParserException(headerValue); + } + + 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 new ParserException(e); + } + } + + return new RtspSessionHeader(sessionId, timeoutMs); + } + + private static String getRtspStatusReasonPhrase(int statusCode) { + switch (statusCode) { + case 200: + return "OK"; + 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 new ParserException(e); + } + } + + private RtspMessageUtil() {} +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspOptionsResponse.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspOptionsResponse.java new file mode 100644 index 0000000000..9a9ce8cf0c --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspOptionsResponse.java @@ -0,0 +1,44 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.message; + +import com.google.common.collect.ImmutableList; +import java.util.List; + +/** Represents an RTSP OPTIONS response. */ +// TODO(b/180434754) Move all classes under message to the parent rtsp package, and change the +// visibility. +public final class RtspOptionsResponse { + /** The response's status code. */ + public final int status; + /** + * A list of methods supported by the RTSP server, encoded as {@link RtspRequest.Method}; or an + * empty list if the server does not disclose the supported methods. + */ + public final ImmutableList supportedMethods; + + /** + * Creates a new instance. + * + * @param status The response's status code. + * @param supportedMethods A list of methods supported by the RTSP server, encoded as {@link + * RtspRequest.Method}; or an empty list if such information is not available. + */ + public RtspOptionsResponse(int status, List supportedMethods) { + this.status = status; + this.supportedMethods = ImmutableList.copyOf(supportedMethods); + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspPlayResponse.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspPlayResponse.java new file mode 100644 index 0000000000..5a29de4a9c --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspPlayResponse.java @@ -0,0 +1,47 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.message; + +import com.google.android.exoplayer2.source.rtsp.RtspSessionTiming; +import com.google.android.exoplayer2.source.rtsp.RtspTrackTiming; +import com.google.common.collect.ImmutableList; +import java.util.List; + +/** Represents an RTSP PLAY response. */ +public final class RtspPlayResponse { + /** The response's status code. */ + public final int status; + /** The playback start timing, {@link RtspSessionTiming#DEFAULT} if not present. */ + public final RtspSessionTiming sessionTiming; + /** The list of {@link RtspTrackTiming} representing the {@link RtspHeaders#RTP_INFO} header. */ + public final ImmutableList trackTimingList; + + /** + * Creates a new instance. + * + * @param status The response's status code. + * @param sessionTiming The {@link RtspSessionTiming}, pass {@link RtspSessionTiming#DEFAULT} if + * not present. + * @param trackTimingList The list of {@link RtspTrackTiming} representing the {@link + * RtspHeaders#RTP_INFO} header. + */ + public RtspPlayResponse( + int status, RtspSessionTiming sessionTiming, List trackTimingList) { + this.status = status; + this.sessionTiming = sessionTiming; + this.trackTimingList = ImmutableList.copyOf(trackTimingList); + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspRequest.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspRequest.java new file mode 100644 index 0000000000..17ef7f547a --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspRequest.java @@ -0,0 +1,105 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.message; + +import android.net.Uri; +import androidx.annotation.IntDef; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Represents an RTSP request. */ +public final class RtspRequest { + /** + * RTSP request methods, as defined in RFC2326 Section 10. + * + *

The possible values are: + * + *

    + *
  • {@link #METHOD_UNSET} + *
  • {@link #METHOD_ANNOUNCE} + *
  • {@link #METHOD_DESCRIBE} + *
  • {@link #METHOD_GET_PARAMETER} + *
  • {@link #METHOD_OPTIONS} + *
  • {@link #METHOD_PAUSE} + *
  • {@link #METHOD_PLAY} + *
  • {@link #METHOD_PLAY_NOTIFY} + *
  • {@link #METHOD_RECORD} + *
  • {@link #METHOD_REDIRECT} + *
  • {@link #METHOD_SETUP} + *
  • {@link #METHOD_SET_PARAMETER} + *
  • {@link #METHOD_TEARDOWN} + *
+ */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + value = { + METHOD_UNSET, + METHOD_ANNOUNCE, + METHOD_DESCRIBE, + METHOD_GET_PARAMETER, + METHOD_OPTIONS, + METHOD_PAUSE, + METHOD_PLAY, + METHOD_PLAY_NOTIFY, + METHOD_RECORD, + METHOD_REDIRECT, + METHOD_SETUP, + METHOD_SET_PARAMETER, + METHOD_TEARDOWN + }) + public @interface Method {} + + public static final int METHOD_UNSET = 0; + public static final int METHOD_ANNOUNCE = 1; + public static final int METHOD_DESCRIBE = 2; + public static final int METHOD_GET_PARAMETER = 3; + public static final int METHOD_OPTIONS = 4; + public static final int METHOD_PAUSE = 5; + public static final int METHOD_PLAY = 6; + public static final int METHOD_PLAY_NOTIFY = 7; + public static final int METHOD_RECORD = 8; + public static final int METHOD_REDIRECT = 9; + public static final int METHOD_SETUP = 10; + public static final int METHOD_SET_PARAMETER = 11; + public static final int METHOD_TEARDOWN = 12; + + /** The {@link Uri} to which this request is sent. */ + public final Uri uri; + /** The request method, as defined in {@link Method}. */ + @Method public final int method; + /** The headers of this request. */ + public final RtspHeaders headers; + /** The body of this RTSP message, or empty string if absent. */ + public final String messageBody; + + /** + * Creates a new instance. + * + * @param uri The {@link Uri} to which this request is sent. + * @param method The request method, as defined in {@link Method}. + * @param headers The headers of this request. + * @param messageBody The body of this RTSP message, or empty string if absent. + */ + public RtspRequest(Uri uri, @Method int method, RtspHeaders headers, String messageBody) { + this.uri = uri; + this.method = method; + this.headers = headers; + this.messageBody = messageBody; + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspResponse.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspResponse.java new file mode 100644 index 0000000000..298a4a1be4 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspResponse.java @@ -0,0 +1,44 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.message; + +/** Represents an RTSP Response. */ +public final class RtspResponse { + // TODO(b/172331505) Move this constant to MimeTypes. + /** The MIME type associated with Session Description Protocol (RFC4566). */ + public static final String SDP_MIME_TYPE = "application/sdp"; + + /** The status code of this response, as defined in RFC 2326 section 11. */ + public final int status; + /** The headers of this response. */ + public final RtspHeaders headers; + /** The body of this RTSP message, or empty string if absent. */ + public final String messageBody; + + /** + * Creates a new instance. + * + * @param status The status code of this response, as defined in RFC 2326 section 11. + * @param headers The headers of this response. + * @param messageBody The body of this RTSP message, or empty string if absent. + */ + public RtspResponse(int status, RtspHeaders headers, String messageBody) { + this.status = status; + this.headers = headers; + this.messageBody = messageBody; + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspSetupResponse.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspSetupResponse.java new file mode 100644 index 0000000000..102b0754ae --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/RtspSetupResponse.java @@ -0,0 +1,43 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.message; + +/** Represents an RTSP SETUP response. */ +// TODO(b/180434754) Move all classes under message to the parent rtsp package, and change the +// visibility. +public final class RtspSetupResponse { + + /** The response's status code. */ + public final int status; + /** The Session header (RFC2326 Section 12.37). */ + public final RtspMessageUtil.RtspSessionHeader sessionHeader; + /** The Transport header (RFC2326 Section 12.39). */ + public final String transport; + + /** + * Creates a new instance. + * + * @param status The response's status code. + * @param sessionHeader The {@link RtspMessageUtil.RtspSessionHeader}. + * @param transport The transport header included in the RTSP SETUP response. + */ + public RtspSetupResponse( + int status, RtspMessageUtil.RtspSessionHeader sessionHeader, String transport) { + this.status = status; + this.sessionHeader = sessionHeader; + this.transport = transport; + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/package-info.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/package-info.java new file mode 100644 index 0000000000..7f182ffcf2 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/message/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright 2020 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. + */ +@NonNullApi +package com.google.android.exoplayer2.source.rtsp.message; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/package-info.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/package-info.java new file mode 100644 index 0000000000..3b5e2fefa0 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright 2020 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. + */ +@NonNullApi +package com.google.android.exoplayer2.source.rtsp; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpDataLoadable.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpDataLoadable.java new file mode 100644 index 0000000000..2e04b860df --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpDataLoadable.java @@ -0,0 +1,211 @@ +/* + * Copyright 2020 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 com.google.android.exoplayer2.source.rtsp.rtp; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.closeQuietly; + +import android.net.Uri; +import android.os.Handler; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.extractor.DefaultExtractorInput; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.PositionHolder; +import com.google.android.exoplayer2.source.rtsp.RtspMediaTrack; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.Loader; +import com.google.android.exoplayer2.upstream.UdpDataSource; +import com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * A {@link Loader.Loadable} that sets up a sockets listening to incoming RTP traffic, carried by + * UDP packets. + * + *

Uses a {@link UdpDataSource} to listen on incoming packets. The local UDP port is selected by + * the runtime on opening; it also opens another {@link UdpDataSource} for RTCP on the RTP UDP port + * number plus one one. Pass a listener via constructor to receive a callback when the local port is + * opened. {@link #load} will throw an {@link IOException} if either of the two data sources fails + * to open. + * + *

Received RTP packets' payloads will be extracted by an {@link RtpExtractor}, and will be + * written to the {@link ExtractorOutput} instance provided at construction. + */ +public final class RtpDataLoadable implements Loader.Loadable { + + /** Called on loadable events. */ + public interface EventListener { + /** + * Called when the transport information for receiving incoming RTP and RTCP packets is ready. + * + * @param transport The RTSP transport (RFC2326 Section 12.39) including the client data port + * and RTCP port. + */ + void onTransportReady(String transport); + } + + private static final String DEFAULT_TRANSPORT_FORMAT = "RTP/AVP;unicast;client_port=%d-%d"; + + private static final String RTP_ANY_INCOMING_IPV4 = "rtp://0.0.0.0"; + // Using port zero will cause the system to generate a port. + private static final int RTP_LOCAL_PORT = 0; + private static final String RTP_BIND_ADDRESS = RTP_ANY_INCOMING_IPV4 + ":" + RTP_LOCAL_PORT; + + /** The track ID associated with the Loadable. */ + public final int trackId; + /** The {@link RtspMediaTrack} to load. */ + public final RtspMediaTrack rtspMediaTrack; + + private final EventListener eventListener; + private final ExtractorOutput output; + private final Handler playbackThreadHandler; + + private @MonotonicNonNull RtpExtractor extractor; + + private volatile boolean loadCancelled; + private volatile long pendingSeekPositionUs; + private volatile long nextRtpTimestamp; + + /** + * Creates an {@link RtpDataLoadable} that listens on incoming RTP traffic. + * + *

Caller of this constructor must be on playback thread. + * + * @param trackId The track ID associated with the Loadable. + * @param rtspMediaTrack The {@link RtspMediaTrack} to load. + * @param eventListener The {@link EventListener}. + * @param output A {@link ExtractorOutput} instance to which the received and extracted data will + */ + public RtpDataLoadable( + int trackId, + RtspMediaTrack rtspMediaTrack, + EventListener eventListener, + ExtractorOutput output) { + this.trackId = trackId; + this.rtspMediaTrack = rtspMediaTrack; + this.eventListener = eventListener; + this.output = output; + this.playbackThreadHandler = Util.createHandlerForCurrentLooper(); + pendingSeekPositionUs = C.TIME_UNSET; + } + + /** + * Sets the timestamp of an RTP packet to arrive. + * + * @param timestamp The timestamp of the RTP packet to arrive. Supply {@link C#TIME_UNSET} if its + * unavailable. + */ + public void setTimestamp(long timestamp) { + if (timestamp != C.TIME_UNSET) { + if (!checkNotNull(extractor).hasReadFirstRtpPacket()) { + extractor.setFirstTimestamp(timestamp); + } + } + } + + /** + * Sets the timestamp of an RTP packet to arrive. + * + * @param sequenceNumber The sequence number of the RTP packet to arrive. Supply {@link + * C#INDEX_UNSET} if its unavailable. + */ + public void setSequenceNumber(int sequenceNumber) { + if (!checkNotNull(extractor).hasReadFirstRtpPacket()) { + extractor.setFirstSequenceNumber(sequenceNumber); + } + } + + @Override + public void cancelLoad() { + loadCancelled = true; + } + + @Override + public void load() throws IOException { + @Nullable UdpDataSource firstDataSource = null; + @Nullable UdpDataSource secondDataSource = null; + + try { + // Open and set up the data sources. + // From RFC3550 Section 11: "For UDP and similar protocols, RTP SHOULD use an even destination + // port number and the corresponding RTCP stream SHOULD use the next higher (odd) destination + // port number". Some RTSP servers are strict about this rule. + // We open a data source first, and depending its port number, open the next data source with + // a port number that is either the higher or the lower. + firstDataSource = new UdpDataSource(); + firstDataSource.open(new DataSpec(Uri.parse(RTP_BIND_ADDRESS))); + + int firstPort = firstDataSource.getLocalPort(); + boolean isFirstPortNumberEven = (firstPort % 2 == 0); + int secondPort = isFirstPortNumberEven ? firstPort + 1 : firstPort - 1; + + // RTCP always uses the immediate next port. + secondDataSource = new UdpDataSource(); + secondDataSource.open(new DataSpec(Uri.parse(RTP_ANY_INCOMING_IPV4 + ":" + secondPort))); + + // RTP data port is always the lower and even-numbered port. + UdpDataSource dataSource = isFirstPortNumberEven ? firstDataSource : secondDataSource; + int dataPort = dataSource.getLocalPort(); + int rtcpPort = dataPort + 1; + String transport = Util.formatInvariant(DEFAULT_TRANSPORT_FORMAT, dataPort, rtcpPort); + playbackThreadHandler.post(() -> eventListener.onTransportReady(transport)); + + // Sets up the extractor. + ExtractorInput extractorInput = + new DefaultExtractorInput( + checkNotNull(dataSource), /* position= */ 0, /* length= */ C.LENGTH_UNSET); + extractor = new RtpExtractor(rtspMediaTrack.payloadFormat, trackId); + extractor.init(output); + + while (!loadCancelled) { + if (pendingSeekPositionUs != C.TIME_UNSET) { + extractor.seek(nextRtpTimestamp, pendingSeekPositionUs); + pendingSeekPositionUs = C.TIME_UNSET; + } + + extractor.read(extractorInput, /* seekPosition= */ new PositionHolder()); + } + } finally { + closeQuietly(firstDataSource); + closeQuietly(secondDataSource); + } + } + + /** + * Signals when performing an RTSP seek that involves RTSP message exchange. + * + *

{@link #seekToUs} must be called after the seek is successful. + */ + public void resetForSeek() { + checkNotNull(extractor).preSeek(); + } + + /** + * Sets the correct start position and RTP timestamp after a successful RTSP seek. + * + * @param positionUs The position in microseconds from the start, from which the server starts + * play. + * @param nextRtpTimestamp The first RTP packet's timestamp after the seek. + */ + public void seekToUs(long positionUs, long nextRtpTimestamp) { + pendingSeekPositionUs = positionUs; + this.nextRtpTimestamp = nextRtpTimestamp; + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpExtractor.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpExtractor.java new file mode 100644 index 0000000000..3f3ed76115 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpExtractor.java @@ -0,0 +1,205 @@ +/* + * Copyright 2020 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 com.google.android.exoplayer2.source.rtsp.rtp; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import android.os.SystemClock; +import androidx.annotation.GuardedBy; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.PositionHolder; +import com.google.android.exoplayer2.extractor.SeekMap; +import com.google.android.exoplayer2.source.rtsp.rtp.reader.DefaultRtpPayloadReaderFactory; +import com.google.android.exoplayer2.source.rtsp.rtp.reader.RtpPayloadReader; +import com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** Extracts data from RTP packets. */ +/* package */ final class RtpExtractor implements Extractor { + + private final RtpPayloadReader payloadReader; + private final ParsableByteArray rtpPacketScratchBuffer; + private final ParsableByteArray rtpPacketDataBuffer; + private final int trackId; + private final Object lock; + private final RtpPacketReorderingQueue reorderingQueue; + + private @MonotonicNonNull ExtractorOutput output; + private boolean firstPacketRead; + private volatile long firstTimestamp; + private volatile int firstSequenceNumber; + + @GuardedBy("lock") + private boolean isSeekPending; + + @GuardedBy("lock") + private long nextRtpTimestamp; + + @GuardedBy("lock") + private long playbackStartTimeUs; + + public RtpExtractor(RtpPayloadFormat payloadFormat, int trackId) { + this.trackId = trackId; + + payloadReader = + checkNotNull(new DefaultRtpPayloadReaderFactory().createPayloadReader(payloadFormat)); + rtpPacketScratchBuffer = new ParsableByteArray(RtpPacket.MAX_SIZE); + rtpPacketDataBuffer = new ParsableByteArray(); + lock = new Object(); + reorderingQueue = new RtpPacketReorderingQueue(); + firstTimestamp = C.TIME_UNSET; + firstSequenceNumber = C.INDEX_UNSET; + nextRtpTimestamp = C.TIME_UNSET; + playbackStartTimeUs = C.TIME_UNSET; + } + + /** Sets the timestamp of the first RTP packet to arrive. */ + public void setFirstTimestamp(long firstTimestamp) { + this.firstTimestamp = firstTimestamp; + } + + /** Sets the sequence number of the first RTP packet to arrive. */ + public void setFirstSequenceNumber(int firstSequenceNumber) { + this.firstSequenceNumber = firstSequenceNumber; + } + + /** Returns whether the first RTP packet is processed. */ + public boolean hasReadFirstRtpPacket() { + return firstPacketRead; + } + + /** + * Signals when performing an RTSP seek that involves RTSP message exchange. + * + *

{@link #seek} must be called after a successful RTSP seek. + * + *

After this method in called, the incoming RTP packets are read from the {@link + * ExtractorInput}, but they are not further processed by the {@link RtpPayloadReader readers}. + * + *

The user must clear the {@link ExtractorOutput} after calling this method, to ensure no + * samples are written to {@link ExtractorOutput}. + */ + public void preSeek() { + synchronized (lock) { + isSeekPending = true; + } + } + + @Override + public boolean sniff(ExtractorInput input) { + // TODO(b/172331505) Build sniff support. + return false; + } + + @Override + public void init(ExtractorOutput output) { + payloadReader.createTracks(output, trackId); + output.endTracks(); + // TODO(b/172331505) replace hardcoded unseekable seekmap. + output.seekMap(new SeekMap.Unseekable(C.TIME_UNSET)); + this.output = output; + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException { + checkNotNull(output); // Asserts init is called. + + // Reads one RTP packet at a time. + int bytesRead = input.read(rtpPacketScratchBuffer.getData(), 0, RtpPacket.MAX_SIZE); + if (bytesRead == RESULT_END_OF_INPUT) { + return RESULT_END_OF_INPUT; + } else if (bytesRead == 0) { + return RESULT_CONTINUE; + } + + rtpPacketScratchBuffer.setPosition(0); + rtpPacketScratchBuffer.setLimit(bytesRead); + @Nullable RtpPacket packet = RtpPacket.parse(rtpPacketScratchBuffer); + if (packet == null) { + return RESULT_CONTINUE; + } + + long packetArrivalTimeMs = SystemClock.elapsedRealtime(); + reorderingQueue.offer(packet, packetArrivalTimeMs); + @Nullable RtpPacket dequeuedPacket = reorderingQueue.poll(getCutoffTimeMs(packetArrivalTimeMs)); + if (dequeuedPacket == null) { + // No packet is available for reading. + return RESULT_CONTINUE; + } + packet = dequeuedPacket; + + if (!firstPacketRead) { + // firstTimestamp and firstSequenceNumber are transmitted over RTSP. There is no guarantee + // that they arrive before the RTP packets. We use whichever comes first. + if (firstTimestamp == C.TIME_UNSET) { + firstTimestamp = packet.timestamp; + } + if (firstSequenceNumber == C.INDEX_UNSET) { + firstSequenceNumber = packet.sequenceNumber; + } + payloadReader.onReceivingFirstPacket(firstTimestamp, firstSequenceNumber); + firstPacketRead = true; + } + + synchronized (lock) { + // Ignores the incoming packets while seek is pending. + if (isSeekPending) { + if (nextRtpTimestamp != C.TIME_UNSET && playbackStartTimeUs != C.TIME_UNSET) { + payloadReader.seek(nextRtpTimestamp, playbackStartTimeUs); + isSeekPending = false; + nextRtpTimestamp = C.TIME_UNSET; + playbackStartTimeUs = C.TIME_UNSET; + } + } else { + rtpPacketDataBuffer.reset(packet.payloadData); + payloadReader.consume( + rtpPacketDataBuffer, packet.timestamp, packet.sequenceNumber, packet.marker); + } + } + return RESULT_CONTINUE; + } + + @Override + public void seek(long nextRtpTimestamp, long playbackStartTimeUs) { + synchronized (lock) { + this.nextRtpTimestamp = nextRtpTimestamp; + this.playbackStartTimeUs = playbackStartTimeUs; + } + } + + @Override + public void release() { + // Do nothing. + } + + /** + * Returns the cutoff time of waiting for an out-of-order packet. + * + *

Returns the cutoff time to pass to {@link RtpPacketReorderingQueue#poll(long)} based on the + * given RtpPacket arrival time. + */ + private static long getCutoffTimeMs(long packetArrivalTimeMs) { + // TODO(internal b/172331505) 30ms is roughly the time for one video frame. It is not rigorously + // chosen and will need fine tuning in the future. + return packetArrivalTimeMs - 30; + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpPacket.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpPacket.java new file mode 100644 index 0000000000..2377836012 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpPacket.java @@ -0,0 +1,324 @@ +/* + * Copyright 2020 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 com.google.android.exoplayer2.source.rtsp.rtp; + +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.android.exoplayer2.util.Util; +import java.nio.ByteBuffer; + +/** + * Represents the header and the payload of an RTP packet. + * + *

Not supported parsing at the moment: header extension and CSRC. + * + *

Structure of an RTP header (RFC3550, Section 5.1). + * + *

+ *  0                   1                   2                   3
+ *  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * |V=2|P|X|  CC   |M|     PT      |       sequence number         |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * |                           timestamp                           |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * |           synchronization source (SSRC) identifier            |
+ * +=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+
+ * |            contributing source (CSRC) identifiers             |
+ * |                             ....                              |
+ * +=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+
+ * | Profile-specific extension ID |   Extension header length     |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * |                       Extension header                        |
+ * |                             ....                              |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ *    3                   2                   1
+ *  1 0 9 8 7 6 5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0
+ * 
+ */ +public final class RtpPacket { + + /** Builder class for an {@link RtpPacket} */ + public static final class Builder { + private boolean padding; + private boolean marker; + private byte payloadType; + private int sequenceNumber; + private long timestamp; + private int ssrc; + private byte[] csrc = EMPTY; + private byte[] payloadData = EMPTY; + + /** Sets the {@link RtpPacket#padding}. The default is false. */ + public Builder setPadding(boolean padding) { + this.padding = padding; + return this; + } + + /** Sets {@link RtpPacket#marker}. The default is false. */ + public Builder setMarker(boolean marker) { + this.marker = marker; + return this; + } + + /** Sets {@link RtpPacket#payloadType}. The default is 0. */ + public Builder setPayloadType(byte payloadType) { + this.payloadType = payloadType; + return this; + } + + /** Sets {@link RtpPacket#sequenceNumber}. The default is 0. */ + public Builder setSequenceNumber(int sequenceNumber) { + checkArgument(sequenceNumber >= MIN_SEQUENCE_NUMBER && sequenceNumber <= MAX_SEQUENCE_NUMBER); + this.sequenceNumber = sequenceNumber & 0xFFFF; + return this; + } + + /** Sets {@link RtpPacket#timestamp}. The default is 0. */ + public Builder setTimestamp(long timestamp) { + this.timestamp = timestamp; + return this; + } + + /** Sets {@link RtpPacket#ssrc}. The default is 0. */ + public Builder setSsrc(int ssrc) { + this.ssrc = ssrc; + return this; + } + + /** Sets {@link RtpPacket#csrc}. The default is an empty byte array. */ + public Builder setCsrc(byte[] csrc) { + checkNotNull(csrc); + this.csrc = csrc; + return this; + } + + /** Sets {@link RtpPacket#payloadData}. The default is an empty byte array. */ + public Builder setPayloadData(byte[] payloadData) { + checkNotNull(payloadData); + this.payloadData = payloadData; + return this; + } + + /** Builds the {@link RtpPacket}. */ + public RtpPacket build() { + return new RtpPacket(this); + } + } + + public static final int RTP_VERSION = 2; + + public static final int MAX_SIZE = 65507; + public static final int MIN_HEADER_SIZE = 12; + public static final int MIN_SEQUENCE_NUMBER = 0; + public static final int MAX_SEQUENCE_NUMBER = 0xFFFF; + public static final int CSRC_SIZE = 4; + + private static final byte[] EMPTY = new byte[0]; + + /** The RTP version field (Word 0, bits 0-1), should always be 2. */ + public final byte version = RTP_VERSION; + /** The RTP padding bit (Word 0, bit 2). */ + public final boolean padding; + /** The RTP extension bit (Word 0, bit 3). */ + public final boolean extension; + /** The RTP CSRC count field (Word 0, bits 4-7). */ + public final byte csrcCount; + + /** The RTP marker bit (Word 0, bit 8). */ + public final boolean marker; + /** The RTP CSRC count field (Word 0, bits 9-15). */ + public final byte payloadType; + + /** The RTP sequence number field (Word 0, bits 16-31). */ + public final int sequenceNumber; + + /** The RTP timestamp field (Word 1). */ + public final long timestamp; + + /** The RTP SSRC field (Word 2). */ + public final int ssrc; + + /** The RTP CSRC fields (Optional, up to 15 items). */ + public final byte[] csrc; + + public final byte[] payloadData; + + /** + * Creates an {@link RtpPacket} from a {@link ParsableByteArray}. + * + * @param packetBuffer The buffer that contains the RTP packet data. + * @return The built {@link RtpPacket}. + */ + @Nullable + public static RtpPacket parse(ParsableByteArray packetBuffer) { + if (packetBuffer.bytesLeft() < MIN_HEADER_SIZE) { + return null; + } + + // Word 0. + int firstByte = packetBuffer.readUnsignedByte(); + byte version = (byte) (firstByte >> 6); + boolean padding = ((firstByte >> 5) & 0x1) == 1; + byte csrcCount = (byte) (firstByte & 0xF); + + if (version != RTP_VERSION) { + return null; + } + + int secondByte = packetBuffer.readUnsignedByte(); + boolean marker = ((secondByte >> 7) & 0x1) == 1; + byte payloadType = (byte) (secondByte & 0x7F); + + int sequenceNumber = packetBuffer.readUnsignedShort(); + + // Word 1. + long timestamp = packetBuffer.readUnsignedInt(); + + // Word 2. + int ssrc = packetBuffer.readInt(); + + // CSRC. + byte[] csrc; + if (csrcCount > 0) { + csrc = new byte[csrcCount * CSRC_SIZE]; + for (int i = 0; i < csrcCount; i++) { + packetBuffer.readBytes(csrc, i * CSRC_SIZE, CSRC_SIZE); + } + } else { + csrc = EMPTY; + } + + // Everything else will be RTP payload. + byte[] payloadData = new byte[packetBuffer.bytesLeft()]; + packetBuffer.readBytes(payloadData, 0, packetBuffer.bytesLeft()); + + Builder builder = new Builder(); + return builder + .setPadding(padding) + .setMarker(marker) + .setPayloadType(payloadType) + .setSequenceNumber(sequenceNumber) + .setTimestamp(timestamp) + .setSsrc(ssrc) + .setCsrc(csrc) + .setPayloadData(payloadData) + .build(); + } + + /** + * Creates an {@link RtpPacket} from a byte array. + * + * @param buffer The buffer that contains the RTP packet data. + * @param length The length of the RTP packet. + * @return The built {@link RtpPacket}. + */ + @Nullable + public static RtpPacket parse(byte[] buffer, int length) { + return parse(new ParsableByteArray(buffer, length)); + } + + private RtpPacket(Builder builder) { + this.padding = builder.padding; + this.extension = false; + this.marker = builder.marker; + this.payloadType = builder.payloadType; + this.sequenceNumber = builder.sequenceNumber; + this.timestamp = builder.timestamp; + this.ssrc = builder.ssrc; + this.csrc = builder.csrc; + this.csrcCount = (byte) (this.csrc.length / CSRC_SIZE); + this.payloadData = builder.payloadData; + } + + /** + * Writes the data in an RTP packet to a target buffer. + * + *

The size of the target buffer and the length argument should be big enough so that the + * entire RTP packet could fit. That is, if there is not enough space to store the entire RTP + * packet, no bytes will be written. The maximum size of an RTP packet is defined as {@link + * RtpPacket#MAX_SIZE}. + * + * @param target A target byte buffer to which the packet data is copied. + * @param offset The offset into the target array at which to write. + * @param length The maximum number of bytes that can be written. + * @return The number of bytes written, or {@link C#LENGTH_UNSET} if there is not enough space to + * write the packet. + */ + public int writeToBuffer(byte[] target, int offset, int length) { + int packetLength = MIN_HEADER_SIZE + (CSRC_SIZE * csrcCount) + payloadData.length; + if (length < packetLength || target.length - offset < packetLength) { + return C.LENGTH_UNSET; + } + + ByteBuffer buffer = ByteBuffer.wrap(target, offset, length); + byte firstByte = + (byte) + ((version << 6) + | ((padding ? 1 : 0) << 5) + | ((extension ? 1 : 0) << 4) + | (csrcCount & 0xF)); + byte secondByte = (byte) (((marker ? 1 : 0) << 7) | (payloadType & 0x7F)); + buffer + .put(firstByte) + .put(secondByte) + .putShort((short) sequenceNumber) + .putInt((int) timestamp) + .putInt(ssrc) + .put(csrc) + .put(payloadData); + return packetLength; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RtpPacket rtpPacket = (RtpPacket) o; + return payloadType == rtpPacket.payloadType + && sequenceNumber == rtpPacket.sequenceNumber + && marker == rtpPacket.marker + && timestamp == rtpPacket.timestamp + && ssrc == rtpPacket.ssrc; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + payloadType; + result = 31 * result + sequenceNumber; + result = 31 * result + (marker ? 1 : 0); + result = 31 * result + (int) (timestamp ^ (timestamp >>> 32)); + result = 31 * result + ssrc; + return result; + } + + @Override + public String toString() { + return Util.formatInvariant( + "RtpPacket(payloadType=%d, seq=%d, timestamp=%d, ssrc=%x, marker=%b)", + payloadType, sequenceNumber, timestamp, ssrc, marker); + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpPacketReorderingQueue.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpPacketReorderingQueue.java new file mode 100644 index 0000000000..c502fad893 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpPacketReorderingQueue.java @@ -0,0 +1,201 @@ +/* + * Copyright 2020 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 com.google.android.exoplayer2.source.rtsp.rtp; + +import static java.lang.Math.abs; +import static java.lang.Math.max; +import static java.lang.Math.min; + +import androidx.annotation.GuardedBy; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import com.google.android.exoplayer2.C; +import java.util.TreeSet; + +/** + * Orders RTP packets by their sequence numbers to correct the possible alternation in packet + * ordering, introduced by UDP transport. + */ +/* package */ final class RtpPacketReorderingQueue { + /** The maximum sequence number discontinuity allowed without resetting the re-ordering buffer. */ + @VisibleForTesting /* package */ static final int MAX_SEQUENCE_LEAP_ALLOWED = 1000; + + private static final int MAX_SEQUENCE_NUMBER = RtpPacket.MAX_SEQUENCE_NUMBER; + + // Use set to eliminate duplicating packets. + // TODO(b/172331505) Set a upper limit on packetQueue to mitigate out of memory error. + @GuardedBy("this") + private final TreeSet packetQueue; + + @GuardedBy("this") + private int lastReceivedSequenceNumber; + + @GuardedBy("this") + private int lastDequeuedSequenceNumber; + + @GuardedBy("this") + private boolean started; + + /** Creates an instance. */ + public RtpPacketReorderingQueue() { + packetQueue = + new TreeSet<>( + (packetContainer1, packetContainer2) -> + calculateSequenceNumberShift( + packetContainer1.packet.sequenceNumber, + packetContainer2.packet.sequenceNumber)); + + reset(); + } + + public synchronized void reset() { + packetQueue.clear(); + started = false; + lastDequeuedSequenceNumber = C.INDEX_UNSET; + lastReceivedSequenceNumber = C.INDEX_UNSET; + } + + /** + * Offer one packet to the reordering queue. + * + *

A packet will not be added to the queue, if a logically preceding packet has already been + * dequeued. + * + *

If a packet creates a shift in sequence number that is at least {@link + * #MAX_SEQUENCE_LEAP_ALLOWED} compared to the last offered packet, the queue is emptied and then + * the packet is added. + * + * @param packet The packet to add. + * @param receivedTimestampMs The timestamp in milliseconds, at which the packet was received. + * @return Returns {@code false} if the packet was dropped because it was outside the expected + * range of accepted packets, otherwise {@code true} (on duplicated packets, this method + * returns {@code true}). + */ + public synchronized boolean offer(RtpPacket packet, long receivedTimestampMs) { + int packetSequenceNumber = packet.sequenceNumber; + if (!started) { + reset(); + lastDequeuedSequenceNumber = prevSequenceNumber(packetSequenceNumber); + started = true; + addToQueue(new RtpPacketContainer(packet, receivedTimestampMs)); + return true; + } + + int expectedSequenceNumber = nextSequenceNumber(lastReceivedSequenceNumber); + // A positive shift means the packet succeeds the last received packet. + int sequenceNumberShift = + calculateSequenceNumberShift(packetSequenceNumber, expectedSequenceNumber); + if (abs(sequenceNumberShift) < MAX_SEQUENCE_LEAP_ALLOWED) { + if (calculateSequenceNumberShift(packetSequenceNumber, lastDequeuedSequenceNumber) > 0) { + // Add the packet in the queue only if a succeeding packet has not been dequeued already. + addToQueue(new RtpPacketContainer(packet, receivedTimestampMs)); + return true; + } + } else { + // Discard all previous received packets and start subsequent receiving from here. + lastDequeuedSequenceNumber = prevSequenceNumber(packetSequenceNumber); + packetQueue.clear(); + addToQueue(new RtpPacketContainer(packet, receivedTimestampMs)); + return true; + } + return false; + } + + /** + * Polls an {@link RtpPacket} from the queue. + * + * @param cutoffTimestampMs A cutoff timestamp in milliseconds used to determine if the head of + * the queue should be dequeued, even if it's not the next packet in sequence. + * @return Returns a packet if the packet at the queue head is the next packet in sequence; or its + * {@link #offer received} timestamp is before {@code cutoffTimestampMs}. Otherwise {@code + * null}. + */ + @Nullable + public synchronized RtpPacket poll(long cutoffTimestampMs) { + if (packetQueue.isEmpty()) { + return null; + } + + RtpPacketContainer packetContainer = packetQueue.first(); + int packetSequenceNumber = packetContainer.packet.sequenceNumber; + + if (packetSequenceNumber == nextSequenceNumber(lastDequeuedSequenceNumber) + || cutoffTimestampMs >= packetContainer.receivedTimestampMs) { + packetQueue.pollFirst(); + lastDequeuedSequenceNumber = packetSequenceNumber; + return packetContainer.packet; + } + + return null; + } + + // Internals. + + private synchronized void addToQueue(RtpPacketContainer packet) { + lastReceivedSequenceNumber = packet.packet.sequenceNumber; + packetQueue.add(packet); + } + + private static final class RtpPacketContainer { + public final RtpPacket packet; + public final long receivedTimestampMs; + + /** Creates an instance. */ + public RtpPacketContainer(RtpPacket packet, long receivedTimestampMs) { + this.packet = packet; + this.receivedTimestampMs = receivedTimestampMs; + } + } + + private static int nextSequenceNumber(int sequenceNumber) { + return (sequenceNumber + 1) % MAX_SEQUENCE_NUMBER; + } + + private static int prevSequenceNumber(int sequenceNumber) { + return sequenceNumber == 0 + ? MAX_SEQUENCE_NUMBER - 1 + : (sequenceNumber - 1) % MAX_SEQUENCE_NUMBER; + } + + /** + * Calculates the sequence number shift, accounting for wrapping around. + * + * @param sequenceNumber The currently received sequence number. + * @param previousSequenceNumber The previous sequence number to compare against. + * @return The shift in the sequence numbers. A positive shift indicates that {@code + * sequenceNumber} is logically after {@code previousSequenceNumber}, whereas a negative shift + * means that {@code sequenceNumber} is logically before {@code previousSequenceNumber}. + */ + private static int calculateSequenceNumberShift(int sequenceNumber, int previousSequenceNumber) { + int sequenceShift = sequenceNumber - previousSequenceNumber; + if (abs(sequenceShift) > MAX_SEQUENCE_LEAP_ALLOWED) { + int shift = + min(sequenceNumber, previousSequenceNumber) + - max(sequenceNumber, previousSequenceNumber) + + MAX_SEQUENCE_NUMBER; + // Check whether this is actually an wrap-over. For example, it is a wrap around if receiving + // 65500 (prevSequenceNumber) after 1 (sequenceNumber); but it is not when prevSequenceNumber + // is 30000. + if (shift < MAX_SEQUENCE_LEAP_ALLOWED) { + return sequenceNumber < previousSequenceNumber + ? /* receiving 65000 (curr) then 1 (prev) */ shift + : /* receiving 1 (curr) then 65500 (prev) */ -shift; + } + } + return sequenceShift; + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpPayloadFormat.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpPayloadFormat.java new file mode 100644 index 0000000000..c80e84eaa6 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpPayloadFormat.java @@ -0,0 +1,127 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.rtp; + +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.source.rtsp.sdp.MediaDescription; +import com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.base.Ascii; +import com.google.common.collect.ImmutableMap; +import java.util.Map; + +/** + * Represents the payload format used in RTP. + * + *

In RTSP playback, the format information is always present in the {@link SessionDescription} + * enclosed in the response of a DESCRIBE request. Within each track's {@link MediaDescription}, it + * is the attributes FMTP and RTPMAP that allows us to recreate the media format. + * + *

This class wraps around the {@link Format} class, in addition to the instance fields that are + * specific to RTP. + */ +public final class RtpPayloadFormat { + + private static final String RTP_MEDIA_AC3 = "AC3"; + private static final String RTP_MEDIA_MPEG4_GENERIC = "MPEG4-GENERIC"; + private static final String RTP_MEDIA_H264 = "H264"; + + /** Returns whether the format of a {@link MediaDescription} is supported. */ + public static boolean isFormatSupported(MediaDescription mediaDescription) { + switch (Ascii.toUpperCase(mediaDescription.rtpMapAttribute.mediaEncoding)) { + case RTP_MEDIA_AC3: + case RTP_MEDIA_H264: + case RTP_MEDIA_MPEG4_GENERIC: + return true; + default: + return false; + } + } + + /** + * Gets the MIME type that is associated with the RTP media type. + * + *

For instance, RTP media type "H264" maps to {@link MimeTypes#VIDEO_H264}. + * + * @throws IllegalArgumentException When the media type is not supported/recognized. + */ + public static String getMimeTypeFromRtpMediaType(String mediaType) { + switch (Ascii.toUpperCase(mediaType)) { + case RTP_MEDIA_AC3: + return MimeTypes.AUDIO_AC3; + case RTP_MEDIA_H264: + return MimeTypes.VIDEO_H264; + case RTP_MEDIA_MPEG4_GENERIC: + return MimeTypes.AUDIO_AAC; + default: + throw new IllegalArgumentException(mediaType); + } + } + + /** The payload type associated with this format. */ + public final int rtpPayloadType; + /** The clock rate in Hertz, associated with the format. */ + public final int clockRate; + /** The {@link Format} of this RTP payload. */ + public final Format format; + /** The format parameters, mapped from the SDP FMTP attribute (RFC2327 Page 22). */ + public final ImmutableMap fmtpParameters; + + /** + * Creates a new instance. + * + * @param format The associated {@link Format media format}. + * @param rtpPayloadType The assigned RTP payload type, from the RTPMAP attribute in {@link + * MediaDescription}. + * @param clockRate The associated clock rate in hertz. + * @param fmtpParameters The format parameters, from the SDP FMTP attribute (RFC2327 Page 22), + * empty if unset. The keys and values are specified in the RFCs for specific formats. For + * instance, RFC3640 Section 4.1 defines keys like profile-level-id and config. + */ + public RtpPayloadFormat( + Format format, int rtpPayloadType, int clockRate, Map fmtpParameters) { + this.rtpPayloadType = rtpPayloadType; + this.clockRate = clockRate; + this.format = format; + this.fmtpParameters = ImmutableMap.copyOf(fmtpParameters); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RtpPayloadFormat that = (RtpPayloadFormat) o; + return rtpPayloadType == that.rtpPayloadType + && clockRate == that.clockRate + && format.equals(that.format) + && fmtpParameters.equals(that.fmtpParameters); + } + + @Override + public int hashCode() { + int result = 7; + result = 31 * result + rtpPayloadType; + result = 31 * result + clockRate; + result = 31 * result + format.hashCode(); + result = 31 * result + fmtpParameters.hashCode(); + return result; + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/package-info.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/package-info.java new file mode 100644 index 0000000000..41e8a4ca7b --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright 2020 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. + */ +@NonNullApi +package com.google.android.exoplayer2.source.rtsp.rtp; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/DefaultRtpPayloadReaderFactory.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/DefaultRtpPayloadReaderFactory.java new file mode 100644 index 0000000000..37f15c4f6b --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/DefaultRtpPayloadReaderFactory.java @@ -0,0 +1,43 @@ +/* + * Copyright 2020 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 com.google.android.exoplayer2.source.rtsp.rtp.reader; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.source.rtsp.rtp.RtpPayloadFormat; +import com.google.android.exoplayer2.util.MimeTypes; + +/** Default {@link RtpPayloadReader.Factory} implementation. */ +public final class DefaultRtpPayloadReaderFactory implements RtpPayloadReader.Factory { + + @Override + @Nullable + public RtpPayloadReader createPayloadReader(RtpPayloadFormat payloadFormat) { + switch (checkNotNull(payloadFormat.format.sampleMimeType)) { + case MimeTypes.AUDIO_AC3: + return new RtpAc3Reader(payloadFormat); + case MimeTypes.AUDIO_AAC: + return new RtpAacReader(payloadFormat); + case MimeTypes.VIDEO_H264: + return new RtpH264Reader(payloadFormat); + default: + // No supported reader, returning null. + } + return null; + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/RtpAacReader.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/RtpAacReader.java new file mode 100644 index 0000000000..eec22888a9 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/RtpAacReader.java @@ -0,0 +1,166 @@ +/* + * Copyright 2020 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 com.google.android.exoplayer2.source.rtsp.rtp.reader; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.source.rtsp.rtp.RtpPayloadFormat; +import com.google.android.exoplayer2.util.ParsableBitArray; +import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Ascii; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * Parses a AAC byte stream carried on RTP packets and extracts individual samples. Interleaving + * mode is not supported. + */ +/* package */ final class RtpAacReader implements RtpPayloadReader { + + /** AAC low bit rate mode, RFC3640 Section 3.3.5. */ + private static final String AAC_LOW_BITRATE_MODE = "AAC-lbr"; + /** AAC high bit rate mode, RFC3640 Section 3.3.6. */ + private static final String AAC_HIGH_BITRATE_MODE = "AAC-hbr"; + + private static final String TAG = "RtpAacReader"; + + private final RtpPayloadFormat payloadFormat; + private final ParsableBitArray auHeaderScratchBit; + private final int sampleRate; + private final int auSizeFieldBitSize; + private final int auIndexFieldBitSize; + private final int numBitsInAuHeader; + + private long firstReceivedTimestamp; + private @MonotonicNonNull TrackOutput trackOutput; + private long startTimeOffsetUs; + + public RtpAacReader(RtpPayloadFormat payloadFormat) { + this.payloadFormat = payloadFormat; + this.auHeaderScratchBit = new ParsableBitArray(); + this.sampleRate = this.payloadFormat.clockRate; + + // mode attribute is mandatory. See RFC3640 Section 4.1. + String mode = checkNotNull(payloadFormat.fmtpParameters.get("mode")); + if (Ascii.equalsIgnoreCase(mode, AAC_HIGH_BITRATE_MODE)) { + auSizeFieldBitSize = 13; + auIndexFieldBitSize = 3; + } else if (Ascii.equalsIgnoreCase(mode, AAC_LOW_BITRATE_MODE)) { + auSizeFieldBitSize = 6; + auIndexFieldBitSize = 2; + } else { + throw new UnsupportedOperationException("AAC mode not supported"); + } + // TODO(b/172331505) Add support for other AU-Header fields, like CTS-flag, CTS-delta, etc. + numBitsInAuHeader = auIndexFieldBitSize + auSizeFieldBitSize; + } + + // RtpPayloadReader implementation. + + @Override + public void createTracks(ExtractorOutput extractorOutput, int trackId) { + trackOutput = extractorOutput.track(trackId, C.TRACK_TYPE_AUDIO); + trackOutput.format(payloadFormat.format); + } + + @Override + public void onReceivingFirstPacket(long timestamp, int sequenceNumber) { + this.firstReceivedTimestamp = timestamp; + } + + @Override + public void consume( + ParsableByteArray data, long timestamp, int sequenceNumber, boolean isFrameBoundary) { + /* + AAC as RTP payload (RFC3640): + +---------+-----------+-----------+---------------+ + | RTP | AU Header | Auxiliary | Access Unit | + | Header | Section | Section | Data Section | + +---------+-----------+-----------+---------------+ + <----------RTP Packet Payload-----------> + + Access Unit(AU) Header section + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+- .. -+-+-+-+-+-+-+-+-+-+ + |AU-headers-length|AU-header|AU-header| |AU-header|padding| + |in bits | (1) | (2) | | (n) | bits | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+- .. -+-+-+-+-+-+-+-+-+-+ + + The 16-bit AU-headers-length is mandatory in the AAC-lbr and AAC-hbr modes that we support. + */ + checkNotNull(trackOutput); + // Reads AU-header-length that specifies the length in bits of the immediately following + // AU-headers, excluding the padding. + int auHeadersBitLength = data.readShort(); + int auHeaderCount = auHeadersBitLength / numBitsInAuHeader; + long sampleTimeUs = + toSampleTimeUs(startTimeOffsetUs, timestamp, firstReceivedTimestamp, sampleRate); + + // Points to the start of the AU-headers (right past the AU-headers-length). + auHeaderScratchBit.reset(data); + if (auHeaderCount == 1) { + // Reads the first AU-Header that contains AU-Size and AU-Index/AU-Index-delta. + int auSize = auHeaderScratchBit.readBits(auSizeFieldBitSize); + auHeaderScratchBit.skipBits(auIndexFieldBitSize); + + // Outputs all the received data, whether fragmented or not. + trackOutput.sampleData(data, data.bytesLeft()); + if (isFrameBoundary) { + outputSampleMetadata(trackOutput, sampleTimeUs, auSize); + } + } else { + // Skips the AU-headers section to the data section, accounts for the possible padding bits. + data.skipBytes((auHeadersBitLength + 7) / 8); + for (int i = 0; i < auHeaderCount; i++) { + int auSize = auHeaderScratchBit.readBits(auSizeFieldBitSize); + auHeaderScratchBit.skipBits(auIndexFieldBitSize); + + trackOutput.sampleData(data, auSize); + outputSampleMetadata(trackOutput, sampleTimeUs, auSize); + // The sample time of the of the i-th AU (RFC3640 Page 17): + // (timestamp-of-the-first-AU) + i * (access-unit-duration) + sampleTimeUs += + Util.scaleLargeTimestamp( + auHeaderCount, /* multiplier= */ C.MICROS_PER_SECOND, /* divisor= */ sampleRate); + } + } + } + + @Override + public void seek(long nextRtpTimestamp, long timeUs) { + firstReceivedTimestamp = nextRtpTimestamp; + startTimeOffsetUs = timeUs; + } + + // Internal methods. + + private static void outputSampleMetadata(TrackOutput trackOutput, long sampleTimeUs, int size) { + trackOutput.sampleMetadata( + sampleTimeUs, C.BUFFER_FLAG_KEY_FRAME, size, /* offset= */ 0, /* encryptionData= */ null); + } + + /** Returns the correct sample time from RTP timestamp, accounting for the AAC sampling rate. */ + private static long toSampleTimeUs( + long startTimeOffsetUs, long rtpTimestamp, long firstReceivedRtpTimestamp, int sampleRate) { + return startTimeOffsetUs + + Util.scaleLargeTimestamp( + rtpTimestamp - firstReceivedRtpTimestamp, + /* multiplier= */ C.MICROS_PER_SECOND, + /* divisor= */ sampleRate); + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/RtpAc3Reader.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/RtpAc3Reader.java new file mode 100644 index 0000000000..4dbf24a3a2 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/RtpAc3Reader.java @@ -0,0 +1,219 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.rtp.reader; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; +import static com.google.android.exoplayer2.util.Util.castNonNull; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.audio.Ac3Util; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.source.rtsp.rtp.RtpPayloadFormat; +import com.google.android.exoplayer2.util.ParsableBitArray; +import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.android.exoplayer2.util.Util; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** Parses an AC3 byte stream carried on RTP packets, and extracts AC3 frames. */ +public final class RtpAc3Reader implements RtpPayloadReader { + + /** AC3 frame types defined in RFC4184 Section 4.1.1. */ + private static final int AC3_FRAME_TYPE_COMPLETE_FRAME = 0; + /** Initial fragment of frame which includes the first 5/8ths of the frame. */ + private static final int AC3_FRAME_TYPE_INITIAL_FRAGMENT_A = 1; + /** Initial fragment of frame which does not include the first 5/8ths of the frame. */ + private static final int AC3_FRAME_TYPE_INITIAL_FRAGMENT_B = 2; + + private static final int AC3_FRAME_TYPE_NON_INITIAL_FRAGMENT = 3; + + /** AC3 payload header size in bytes. */ + private static final int AC3_PAYLOAD_HEADER_SIZE = 2; + + private final RtpPayloadFormat payloadFormat; + private final ParsableBitArray scratchBitBuffer; + + private @MonotonicNonNull TrackOutput trackOutput; + private int numBytesPendingMetadataOutput; + private long firstReceivedTimestamp; + private long sampleTimeUsOfFramePendingMetadataOutput; + private long startTimeOffsetUs; + + public RtpAc3Reader(RtpPayloadFormat payloadFormat) { + this.payloadFormat = payloadFormat; + scratchBitBuffer = new ParsableBitArray(); + firstReceivedTimestamp = C.TIME_UNSET; + } + + @Override + public void createTracks(ExtractorOutput extractorOutput, int trackId) { + trackOutput = extractorOutput.track(trackId, C.TRACK_TYPE_AUDIO); + trackOutput.format(payloadFormat.format); + } + + @Override + public void onReceivingFirstPacket(long timestamp, int sequenceNumber) { + checkState(firstReceivedTimestamp == C.TIME_UNSET); + firstReceivedTimestamp = timestamp; + } + + @Override + public void consume( + ParsableByteArray data, long timestamp, int sequenceNumber, boolean isFrameBoundary) { + /* + AC-3 payload as an RTP payload (RFC4184). + +-+-+-+-+-+-+-+-+-+-+-+-+-+- .. +-+-+-+-+-+-+-+ + | Payload | Frame | Frame | | Frame | + | Header | (1) | (2) | | (n) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+- .. +-+-+-+-+-+-+-+ + + The payload header: + 0 1 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | MBZ | FT| NF | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + FT: frame type. + NF: number of frames/fragments. + */ + int frameType = data.readUnsignedByte() & 0x3; + int numOfFrames = data.readUnsignedByte() & 0xFF; + + long sampleTimeUs = + toSampleTimeUs( + startTimeOffsetUs, timestamp, firstReceivedTimestamp, payloadFormat.clockRate); + + switch (frameType) { + case AC3_FRAME_TYPE_COMPLETE_FRAME: + maybeOutputSampleMetadata(); + if (numOfFrames == 1) { + // Single AC3 frame in one RTP packet. + processSingleFramePacket(data, sampleTimeUs); + } else { + // Multiple AC3 frames in one RTP packet. + processMultiFramePacket(data, numOfFrames, sampleTimeUs); + } + break; + + case AC3_FRAME_TYPE_INITIAL_FRAGMENT_A: + case AC3_FRAME_TYPE_INITIAL_FRAGMENT_B: + maybeOutputSampleMetadata(); + // Falls through. + case AC3_FRAME_TYPE_NON_INITIAL_FRAGMENT: + // The content of an AC3 frame is split into multiple RTP packets. + processFragmentedPacket(data, isFrameBoundary, frameType, sampleTimeUs); + break; + + default: + throw new IllegalArgumentException(String.valueOf(frameType)); + } + } + + @Override + public void seek(long nextRtpTimestamp, long timeUs) { + firstReceivedTimestamp = nextRtpTimestamp; + startTimeOffsetUs = timeUs; + } + + private void processSingleFramePacket(ParsableByteArray data, long sampleTimeUs) { + int frameSize = data.bytesLeft(); + checkNotNull(trackOutput).sampleData(data, frameSize); + castNonNull(trackOutput) + .sampleMetadata( + /* timeUs= */ sampleTimeUs, + /* flags= */ C.BUFFER_FLAG_KEY_FRAME, + /* size= */ frameSize, + /* offset= */ 0, + /* encryptionData= */ null); + } + + private void processMultiFramePacket(ParsableByteArray data, int numOfFrames, long sampleTimeUs) { + // The size of each frame must be obtained by reading AC3 sync frame. + scratchBitBuffer.reset(data.getData()); + // Move the read location after the AC3 payload header. + scratchBitBuffer.skipBytes(AC3_PAYLOAD_HEADER_SIZE); + + for (int i = 0; i < numOfFrames; i++) { + Ac3Util.SyncFrameInfo frameInfo = Ac3Util.parseAc3SyncframeInfo(scratchBitBuffer); + + checkNotNull(trackOutput).sampleData(data, frameInfo.frameSize); + castNonNull(trackOutput) + .sampleMetadata( + /* timeUs= */ sampleTimeUs, + /* flags= */ C.BUFFER_FLAG_KEY_FRAME, + /* size= */ frameInfo.frameSize, + /* offset= */ 0, + /* encryptionData= */ null); + + sampleTimeUs += (frameInfo.sampleCount / frameInfo.sampleRate) * C.MICROS_PER_SECOND; + // Advance the position by the number of bytes read. + scratchBitBuffer.skipBytes(frameInfo.frameSize); + } + } + + private void processFragmentedPacket( + ParsableByteArray data, boolean isFrameBoundary, int frameType, long sampleTimeUs) { + int bytesToWrite = data.bytesLeft(); + checkNotNull(trackOutput).sampleData(data, bytesToWrite); + numBytesPendingMetadataOutput += bytesToWrite; + sampleTimeUsOfFramePendingMetadataOutput = sampleTimeUs; + + if (isFrameBoundary && frameType == AC3_FRAME_TYPE_NON_INITIAL_FRAGMENT) { + // Last RTP packet in the series of fragmentation packets. + outputSampleMetadataForFragmentedPackets(); + } + } + + /** + * Checks and outputs sample metadata, if the last packet of a series of fragmented packets is + * lost. + * + *

Call this method only when receiving an initial packet, i.e. on packets with type + * + *

    + *
  • {@link #AC3_FRAME_TYPE_COMPLETE_FRAME}, + *
  • {@link #AC3_FRAME_TYPE_INITIAL_FRAGMENT_A}, or + *
  • {@link #AC3_FRAME_TYPE_INITIAL_FRAGMENT_B}. + *
+ */ + private void maybeOutputSampleMetadata() { + if (numBytesPendingMetadataOutput > 0) { + outputSampleMetadataForFragmentedPackets(); + } + } + + private void outputSampleMetadataForFragmentedPackets() { + castNonNull(trackOutput) + .sampleMetadata( + /* timeUs= */ sampleTimeUsOfFramePendingMetadataOutput, + /* flags= */ C.BUFFER_FLAG_KEY_FRAME, + /* size= */ numBytesPendingMetadataOutput, + /* offset= */ 0, + /* encryptionData= */ null); + numBytesPendingMetadataOutput = 0; + } + + /** Returns the correct sample time from RTP timestamp, accounting for the AC3 sampling rate. */ + private static long toSampleTimeUs( + long startTimeOffsetUs, long rtpTimestamp, long firstReceivedRtpTimestamp, int sampleRate) { + return startTimeOffsetUs + + Util.scaleLargeTimestamp( + rtpTimestamp - firstReceivedRtpTimestamp, + /* multiplier= */ C.MICROS_PER_SECOND, + /* divisor= */ sampleRate); + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/RtpH264Reader.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/RtpH264Reader.java new file mode 100644 index 0000000000..887a29855a --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/RtpH264Reader.java @@ -0,0 +1,307 @@ +/* + * Copyright 2020 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 com.google.android.exoplayer2.source.rtsp.rtp.reader; + +import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; + +import android.util.Log; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.extractor.TrackOutput; +import com.google.android.exoplayer2.source.rtsp.rtp.RtpPacket; +import com.google.android.exoplayer2.source.rtsp.rtp.RtpPayloadFormat; +import com.google.android.exoplayer2.util.NalUnitUtil; +import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.android.exoplayer2.util.Util; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** Parses an H264 byte stream carried on RTP packets, and extracts H264 Access Units. */ +/* package */ final class RtpH264Reader implements RtpPayloadReader { + private static final String TAG = "RtpH264Reader"; + + // TODO(b/172331505) Move NAL related constants to NalUnitUtil. + private static final ParsableByteArray NAL_START_CODE = + new ParsableByteArray(NalUnitUtil.NAL_START_CODE); + private static final int NAL_START_CODE_LENGTH = NalUnitUtil.NAL_START_CODE.length; + private static final long MEDIA_CLOCK_FREQUENCY = 90_000; + + /** Offset of payload data within a FU type A payload. */ + private static final int FU_PAYLOAD_OFFSET = 2; + + /** Single Time Aggregation Packet type A. */ + private static final int RTP_PACKET_TYPE_STAP_A = 24; + /** Fragmentation Unit type A. */ + private static final int RTP_PACKET_TYPE_FU_A = 28; + + /** IDR NAL unit type. */ + private static final int NAL_UNIT_TYPE_IDR = 5; + + /** Scratch for Fragmentation Unit RTP packets. */ + private final ParsableByteArray fuScratchBuffer; + + private final RtpPayloadFormat payloadFormat; + + private @MonotonicNonNull TrackOutput trackOutput; + @C.BufferFlags private int bufferFlags; + + private long firstReceivedTimestamp; + private int previousSequenceNumber; + /** The combined size of a sample that is fragmented into multiple RTP packets. */ + private int fragmentedSampleSizeBytes; + + private long startTimeOffsetUs; + + /** Creates an instance. */ + public RtpH264Reader(RtpPayloadFormat payloadFormat) { + this.payloadFormat = payloadFormat; + fuScratchBuffer = new ParsableByteArray(); + firstReceivedTimestamp = C.TIME_UNSET; + previousSequenceNumber = C.INDEX_UNSET; + } + + @Override + public void createTracks(ExtractorOutput extractorOutput, int trackId) { + trackOutput = extractorOutput.track(trackId, C.TRACK_TYPE_VIDEO); + + castNonNull(trackOutput).format(payloadFormat.format); + } + + @Override + public void onReceivingFirstPacket(long timestamp, int sequenceNumber) {} + + @Override + public void consume( + ParsableByteArray data, long timestamp, int sequenceNumber, boolean isAuBoundary) + throws ParserException { + + int rtpH264PacketMode; + try { + // RFC6184 Section 5.6, 5.7 and 5.8. + rtpH264PacketMode = data.getData()[0] & 0x1F; + } catch (IndexOutOfBoundsException e) { + throw new ParserException(e); + } + + checkStateNotNull(trackOutput); + if (rtpH264PacketMode > 0 && rtpH264PacketMode < 24) { + processSingleNalUnitPacket(data); + } else if (rtpH264PacketMode == RTP_PACKET_TYPE_STAP_A) { + processSingleTimeAggregationPacket(data); + } else if (rtpH264PacketMode == RTP_PACKET_TYPE_FU_A) { + processFragmentationUnitPacket(data, sequenceNumber); + } else { + throw new ParserException( + String.format("RTP H264 packetization mode [%d] not supported.", rtpH264PacketMode)); + } + + if (isAuBoundary) { + if (firstReceivedTimestamp == C.TIME_UNSET) { + firstReceivedTimestamp = timestamp; + } + + long timeUs = toSampleUs(startTimeOffsetUs, timestamp, firstReceivedTimestamp); + trackOutput.sampleMetadata( + timeUs, + bufferFlags, + fragmentedSampleSizeBytes, + /* offset= */ 0, + /* encryptionData= */ null); + fragmentedSampleSizeBytes = 0; + } + + previousSequenceNumber = sequenceNumber; + } + + @Override + public void seek(long nextRtpTimestamp, long timeUs) { + firstReceivedTimestamp = nextRtpTimestamp; + fragmentedSampleSizeBytes = 0; + startTimeOffsetUs = timeUs; + } + + // Internal methods. + + /** + * Processes Single NAL Unit packet (RFC6184 Section 5.6). + * + *

Outputs the single NAL Unit (with start code prepended) to {@link #trackOutput}. Sets {@link + * #bufferFlags} and {@link #fragmentedSampleSizeBytes} accordingly. + */ + @RequiresNonNull("trackOutput") + private void processSingleNalUnitPacket(ParsableByteArray data) { + // Example of a Single Nal Unit packet + // 0 1 2 3 + // 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + // |F|NRI| Type | | + // +-+-+-+-+-+-+-+-+ | + // | | + // | Bytes 2..n of a single NAL unit | + // | | + // | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + // | :...OPTIONAL RTP padding | + // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + int numBytesInData = data.bytesLeft(); + fragmentedSampleSizeBytes += writeStartCode(trackOutput); + trackOutput.sampleData(data, numBytesInData); + fragmentedSampleSizeBytes += numBytesInData; + + int nalHeaderType = data.getData()[0] & 0x1F; + bufferFlags = getBufferFlagsFromNalType(nalHeaderType); + } + + /** + * Processes STAP Type A packet (RFC6184 Section 5.7). + * + *

Outputs the received aggregation units (with start code prepended) to {@link #trackOutput}. + * Sets {@link #bufferFlags} and {@link #fragmentedSampleSizeBytes} accordingly. + */ + @RequiresNonNull("trackOutput") + private void processSingleTimeAggregationPacket(ParsableByteArray data) { + // Example of an STAP-A packet. + // 0 1 2 3 + // 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + // | RTP Header | + // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + // |STAP-A NAL HDR | NALU 1 Size | NALU 1 HDR | + // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + // | NALU 1 Data | + // : : + // + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + // | | NALU 2 Size | NALU 2 HDR | + // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + // | NALU 2 Data | + // : : + // | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + // | :...OPTIONAL RTP padding | + // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + // Skips STAP-A NAL HDR that has the NAL format |F|NRI|Type|, but with Type replaced by the + // STAP-A type id (RTP_PACKET_TYPE_STAP_A). + data.readUnsignedByte(); + + // Gets all NAL units until the remaining bytes are only enough to store an RTP padding. + int nalUnitLength; + while (data.bytesLeft() > 4) { + nalUnitLength = data.readUnsignedShort(); + fragmentedSampleSizeBytes += writeStartCode(trackOutput); + trackOutput.sampleData(data, nalUnitLength); + fragmentedSampleSizeBytes += nalUnitLength; + } + + // Treat Aggregated NAL units as non key frames. + // TODO(internal b/172331505) examine whether STAP mode carries keyframes. + bufferFlags = 0; + } + + /** + * Processes Fragmentation Unit Type A packet (RFC6184 Section 5.8). + * + *

This method will be invoked multiple times to receive a single frame that is broken down + * into a series of fragmentation units in multiple RTP packets. + * + *

Outputs the received fragmentation units (with start code prepended) to {@link + * #trackOutput}. Sets {@link #bufferFlags} and {@link #fragmentedSampleSizeBytes} accordingly. + */ + @RequiresNonNull("trackOutput") + private void processFragmentationUnitPacket(ParsableByteArray data, int packetSequenceNumber) { + // FU-A mode packet layout. + // 0 1 2 3 + // 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + // | FU indicator | FU header | | + // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | + // | | + // | FU payload | + // | | + // | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + // | :...OPTIONAL RTP padding | + // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + // + // FU Indicator FU Header + // 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 + // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + // |F|NRI| Type |S|E|R| Type | + // +---------------+---------------+ + // Indicator: Upper 3 bits are the same as NALU header, Type = 28 (FU-A type). + // Header: Start/End/Reserved/Type. Type is same as NALU type. + int fuIndicator = data.getData()[0]; + int fuHeader = data.getData()[1]; + int nalHeader = (fuIndicator & 0xE0) | (fuHeader & 0x1F); + boolean isFirstFuPacket = (fuHeader & 0x80) > 0; + boolean isLastFuPacket = (fuHeader & 0x40) > 0; + + if (isFirstFuPacket) { + // Prepends starter code. + fragmentedSampleSizeBytes += writeStartCode(trackOutput); + + // The bytes needed is 1 (NALU header) + payload size. The original data array has size 2 (FU + // indicator/header) + payload size. Thus setting the correct header and set position to 1. + data.getData()[1] = (byte) nalHeader; + fuScratchBuffer.reset(data.getData()); + fuScratchBuffer.setPosition(1); + } else { + // Check that this packet is in the sequence of the previous packet. + int expectedSequenceNumber = (previousSequenceNumber + 1) % RtpPacket.MAX_SEQUENCE_NUMBER; + if (packetSequenceNumber != expectedSequenceNumber) { + Log.w( + TAG, + Util.formatInvariant( + "Received RTP packet with unexpected sequence number. Expected: %d; received: %d." + + " Dropping packet.", + expectedSequenceNumber, packetSequenceNumber)); + return; + } + + // Setting position to ignore FU indicator and header. + fuScratchBuffer.reset(data.getData()); + fuScratchBuffer.setPosition(FU_PAYLOAD_OFFSET); + } + + int fragmentSize = fuScratchBuffer.bytesLeft(); + trackOutput.sampleData(fuScratchBuffer, fragmentSize); + fragmentedSampleSizeBytes += fragmentSize; + + if (isLastFuPacket) { + bufferFlags = getBufferFlagsFromNalType(nalHeader & 0x1F); + } + } + + private static long toSampleUs( + long startTimeOffsetUs, long rtpTimestamp, long firstReceivedRtpTimestamp) { + return startTimeOffsetUs + + Util.scaleLargeTimestamp( + (rtpTimestamp - firstReceivedRtpTimestamp), + /* multiplier= */ C.MICROS_PER_SECOND, + /* divisor= */ MEDIA_CLOCK_FREQUENCY); + } + + private static int writeStartCode(TrackOutput trackOutput) { + trackOutput.sampleData(NAL_START_CODE, NAL_START_CODE_LENGTH); + NAL_START_CODE.setPosition(/* position= */ 0); + return NAL_START_CODE_LENGTH; + } + + @C.BufferFlags + private static int getBufferFlagsFromNalType(int nalType) { + return nalType == NAL_UNIT_TYPE_IDR ? C.BUFFER_FLAG_KEY_FRAME : 0; + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/RtpPayloadReader.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/RtpPayloadReader.java new file mode 100644 index 0000000000..d156b58df4 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/RtpPayloadReader.java @@ -0,0 +1,85 @@ +/* + * Copyright 2020 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 com.google.android.exoplayer2.source.rtsp.rtp.reader; + +import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.source.rtsp.rtp.RtpPayloadFormat; +import com.google.android.exoplayer2.util.ParsableByteArray; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Extracts media samples from the payload of received RTP packets. */ +public interface RtpPayloadReader { + + /** Factory of {@link RtpPayloadReader} instances. */ + interface Factory { + + /** + * Returns a {@link RtpPayloadReader} for a given {@link RtpPayloadFormat}. + * + * @param payloadFormat The {@link RtpPayloadFormat} of the RTP stream. + * @return A {@link RtpPayloadReader} for the packet stream, or {@code null} if the stream + * format is not supported. + */ + @Nullable + RtpPayloadReader createPayloadReader(RtpPayloadFormat payloadFormat); + } + + /** + * Initializes the reader by providing its output and track id. + * + * @param extractorOutput The {@link ExtractorOutput} instance that receives the extracted data. + * @param trackId The track identifier to set on the format. + */ + void createTracks(ExtractorOutput extractorOutput, int trackId); + + /** + * This method should be called on reading the first packet in a stream of incoming packets. + * + * @param timestamp The timestamp associated with the first received RTP packet. This number has + * no unit, the duration conveyed by it depends on the frequency of the media that the RTP + * packet is carrying. + * @param sequenceNumber The sequence associated with the first received RTP packet. + */ + void onReceivingFirstPacket(long timestamp, int sequenceNumber); + + /** + * Consumes the payload from the an RTP packet. + * + * @param data The RTP payload to consume. + * @param timestamp The timestamp of the RTP packet that transmitted the data. This number has no + * unit, the duration conveyed by it depends on the frequency of the media that the RTP packet + * is carrying. + * @param sequenceNumber The sequence number of the RTP packet. + * @param rtpMarker The marker bit of the RTP packet. The interpretation of this bit is specific + * to each payload format. + * @throws ParserException If the data could not be parsed. + */ + void consume(ParsableByteArray data, long timestamp, int sequenceNumber, boolean rtpMarker) + throws ParserException; + + /** + * Seeks the reader. + * + *

This method must only be invoked after the PLAY request for seeking is acknowledged by the + * RTSP server. + * + * @param nextRtpTimestamp The timestamp of the first packet to arrive after seek. + * @param timeUs The server acknowledged seek time in microseconds. + */ + void seek(long nextRtpTimestamp, long timeUs); +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/package-info.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/package-info.java new file mode 100644 index 0000000000..de756ea160 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/rtp/reader/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright 2020 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. + */ +@NonNullApi +package com.google.android.exoplayer2.source.rtsp.rtp.reader; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/sdp/MediaDescription.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/sdp/MediaDescription.java new file mode 100644 index 0000000000..9a1e637a90 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/sdp/MediaDescription.java @@ -0,0 +1,327 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.sdp; + +import static com.google.android.exoplayer2.source.rtsp.message.RtspMessageUtil.parseInt; +import static com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription.ATTR_FMTP; +import static com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription.ATTR_RTPMAP; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkState; +import static com.google.android.exoplayer2.util.Util.castNonNull; + +import androidx.annotation.Nullable; +import androidx.annotation.StringDef; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableMap; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Represents one media description section in a SDP message. */ +public 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.split(rtpmapString, " "); + checkArgument(rtpmapInfo.length == 2); + int payloadType = parseInt(rtpmapInfo[0]); + + String[] mediaInfo = Util.split(rtpmapInfo[1], "/"); + 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 { + private final String mediaType; + private final int port; + private final String transportProtocol; + private final int payloadType; + private final ImmutableMap.Builder attributesBuilder; + + 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; + attributesBuilder = new ImmutableMap.Builder<>(); + 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}. + * + *

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) { + attributesBuilder.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() { + ImmutableMap attributes = attributesBuilder.build(); + try { + // rtpmap attribute is mandatory in RTSP (RFC2326 Section C.1.3). + checkState(attributes.containsKey(ATTR_RTPMAP)); + RtpMapAttribute rtpMapAttribute = + RtpMapAttribute.parse(castNonNull(attributes.get(ATTR_RTPMAP))); + return new MediaDescription(this, attributes, rtpMapAttribute); + } catch (ParserException e) { + throw new IllegalStateException(e); + } + } + } + + /** 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; + // TODO(internal b/172331505) Parse the String representations into objects. + /** The connection parameters. */ + @Nullable public final String connection; + /** The encryption parameter. */ + @Nullable public final String key; + /** The media-specific attributes. */ + public final ImmutableMap 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 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. + * + *

FMTP format reference: RFC2327 Page 27. The spaces around the FMTP attribute delimiters are + * removed. For example, + */ + public ImmutableMap 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: + // =[; =]. + String[] parameters = Util.split(fmtpComponents[1], ";\\s?"); + ImmutableMap.Builder 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.build(); + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/sdp/SessionDescription.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/sdp/SessionDescription.java new file mode 100644 index 0000000000..0ff7f4585c --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/sdp/SessionDescription.java @@ -0,0 +1,316 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.sdp; + +import static com.google.android.exoplayer2.util.Util.castNonNull; + +import android.net.Uri; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +/** + * Records all the information in a SDP message. + * + *

SDP messages encapsulate information on the media play back session, including session + * configuration information, formats of each playable track, etc. SDP is defined in RFC4566. + */ +public final class SessionDescription { + + /** Builder class for {@link SessionDescription}. */ + public static final class Builder { + private final ImmutableMap.Builder attributesBuilder; + private final ImmutableList.Builder mediaDescriptionListBuilder; + private int bitrate; + @Nullable private String sessionName; + @Nullable private String origin; + @Nullable private String timing; + @Nullable private Uri uri; + @Nullable private String connection; + @Nullable private String key; + @Nullable private String sessionInfo; + @Nullable private String emailAddress; + @Nullable private String phoneNumber; + + /** Creates a new instance. */ + public Builder() { + attributesBuilder = new ImmutableMap.Builder<>(); + mediaDescriptionListBuilder = new ImmutableList.Builder<>(); + bitrate = Format.NO_VALUE; + } + + /** + * Sets {@link SessionDescription#sessionName}. + * + *

This property must be set before calling {@link #build()}. + * + * @param sessionName The {@link SessionDescription#sessionName}. + * @return This builder. + */ + public Builder setSessionName(String sessionName) { + this.sessionName = sessionName; + return this; + } + + /** + * Sets {@link SessionDescription#sessionInfo}. The default is {@code null}. + * + * @param sessionInfo The {@link SessionDescription#sessionInfo}. + * @return This builder. + */ + public Builder setSessionInfo(String sessionInfo) { + this.sessionInfo = sessionInfo; + return this; + } + + /** + * Sets {@link SessionDescription#uri}. The default is {@code null}. + * + * @param uri The {@link SessionDescription#uri}. + * @return This builder. + */ + public Builder setUri(Uri uri) { + this.uri = uri; + return this; + } + + /** + * Sets {@link SessionDescription#origin}. + * + *

This property must be set before calling {@link #build()}. + * + * @param origin The {@link SessionDescription#origin}. + * @return This builder. + */ + public Builder setOrigin(String origin) { + this.origin = origin; + return this; + } + + /** + * Sets {@link SessionDescription#connection}. The default is {@code null}. + * + * @param connection The {@link SessionDescription#connection}. + * @return This builder. + */ + public Builder setConnection(String connection) { + this.connection = connection; + return this; + } + + /** + * Sets {@link SessionDescription#bitrate}. The default is {@link Format#NO_VALUE}. + * + * @param bitrate The {@link SessionDescription#bitrate} in bits per second. + * @return This builder. + */ + public Builder setBitrate(int bitrate) { + this.bitrate = bitrate; + return this; + } + + /** + * Sets {@link SessionDescription#timing}. + * + *

This property must be set before calling {@link #build()}. + * + * @param timing The {@link SessionDescription#timing}. + * @return This builder. + */ + public Builder setTiming(String timing) { + this.timing = timing; + return this; + } + + /** + * Sets {@link SessionDescription#key}. The default is {@code null}. + * + * @param key The {@link SessionDescription#key}. + * @return This builder. + */ + public Builder setKey(String key) { + this.key = key; + return this; + } + + /** + * Sets {@link SessionDescription#emailAddress}. The default is {@code null}. + * + * @param emailAddress The {@link SessionDescription#emailAddress}. + * @return This builder. + */ + public Builder setEmailAddress(String emailAddress) { + this.emailAddress = emailAddress; + return this; + } + + /** + * Sets {@link SessionDescription#phoneNumber}. The default is {@code null}. + * + * @param phoneNumber The {@link SessionDescription#phoneNumber}. + * @return This builder. + */ + public Builder setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + return this; + } + + /** + * Adds one attribute to {@link SessionDescription#attributes}. + * + * @param attributeName The name of the attribute. + * @param attributeValue The value of the attribute. + * @return This builder. + */ + public Builder addAttribute(String attributeName, String attributeValue) { + attributesBuilder.put(attributeName, attributeValue); + return this; + } + + /** + * Adds one {@link MediaDescription} to the {@link SessionDescription#mediaDescriptionList}. + * + * @param mediaDescription The {@link MediaDescription}. + * @return This builder. + */ + public Builder addMediaDescription(MediaDescription mediaDescription) { + mediaDescriptionListBuilder.add(mediaDescription); + return this; + } + + /** + * Builds a new {@link SessionDescription} instance. + * + * @return The newly built {@link SessionDescription} instance. + * @throws IllegalStateException When one or more of {@link #sessionName}, {@link #timing} and + * {@link #origin} is not set. + */ + public SessionDescription build() { + if (sessionName == null || origin == null || timing == null) { + throw new IllegalStateException("One of more mandatory SDP fields are not set."); + } + return new SessionDescription(this); + } + } + + /** The only supported SDP version, will be checked against every SDP message received. */ + public static final String SUPPORTED_SDP_VERSION = "0"; + /** The control attribute name. */ + public static final String ATTR_CONTROL = "control"; + /** The format property attribute name. */ + public static final String ATTR_FMTP = "fmtp"; + /** The length property attribute name. */ + public static final String ATTR_LENGTH = "length"; + /** The range property attribute name. */ + public static final String ATTR_RANGE = "range"; + /** The RTP format mapping property attribute name. */ + public static final String ATTR_RTPMAP = "rtpmap"; + /** The tool property attribute name. */ + public static final String ATTR_TOOL = "tool"; + /** The type property attribute name. */ + public static final String ATTR_TYPE = "type"; + + /** + * All the session attributes, mapped from attribute name to value. The value is {@code ""} if not + * present. + */ + public final ImmutableMap attributes; + /** + * The {@link MediaDescription MediaDescriptions} for each media track included in the session. + */ + public final ImmutableList mediaDescriptionList; + /** The name of a session. */ + public final String sessionName; + // TODO(internal b/172331505) Parse the String representations into objects. + /** The origin sender info. */ + public final String origin; + /** The timing info. */ + public final String timing; + /** The estimated bitrate in bits per seconds. */ + public final int bitrate; + /** The uri of a linked content. */ + @Nullable public final Uri uri; + /** The connection info. */ + @Nullable public final String connection; + /** The encryption method and key info. */ + @Nullable public final String key; + /** The email info. */ + @Nullable public final String emailAddress; + /** The phone number info. */ + @Nullable public final String phoneNumber; + /** The session info, a detailed description of the session. */ + @Nullable public final String sessionInfo; + + /** Creates a new instance. */ + private SessionDescription(Builder builder) { + this.attributes = builder.attributesBuilder.build(); + this.mediaDescriptionList = builder.mediaDescriptionListBuilder.build(); + this.sessionName = castNonNull(builder.sessionName); + this.origin = castNonNull(builder.origin); + this.timing = castNonNull(builder.timing); + this.uri = builder.uri; + this.connection = builder.connection; + this.bitrate = builder.bitrate; + this.key = builder.key; + this.emailAddress = builder.emailAddress; + this.phoneNumber = builder.phoneNumber; + this.sessionInfo = builder.sessionInfo; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SessionDescription that = (SessionDescription) o; + return bitrate == that.bitrate + && attributes.equals(that.attributes) + && mediaDescriptionList.equals(that.mediaDescriptionList) + && origin.equals(that.origin) + && sessionName.equals(that.sessionName) + && timing.equals(that.timing) + && Util.areEqual(sessionInfo, that.sessionInfo) + && Util.areEqual(uri, that.uri) + && Util.areEqual(emailAddress, that.emailAddress) + && Util.areEqual(phoneNumber, that.phoneNumber) + && Util.areEqual(connection, that.connection) + && Util.areEqual(key, that.key); + } + + @Override + public int hashCode() { + int result = 7; + result = 31 * result + attributes.hashCode(); + result = 31 * result + mediaDescriptionList.hashCode(); + result = 31 * result + origin.hashCode(); + result = 31 * result + sessionName.hashCode(); + result = 31 * result + timing.hashCode(); + result = 31 * result + bitrate; + result = 31 * result + (sessionInfo == null ? 0 : sessionInfo.hashCode()); + result = 31 * result + (uri == null ? 0 : uri.hashCode()); + result = 31 * result + (emailAddress == null ? 0 : emailAddress.hashCode()); + result = 31 * result + (phoneNumber == null ? 0 : phoneNumber.hashCode()); + result = 31 * result + (connection == null ? 0 : connection.hashCode()); + result = 31 * result + (key == null ? 0 : key.hashCode()); + return result; + } +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/sdp/SessionDescriptionParser.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/sdp/SessionDescriptionParser.java new file mode 100644 index 0000000000..dbc3d9f03b --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/sdp/SessionDescriptionParser.java @@ -0,0 +1,231 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.sdp; + +import static com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription.SUPPORTED_SDP_VERSION; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.common.base.Strings.nullToEmpty; + +import android.net.Uri; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.util.Util; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Parses a String based SDP message into {@link SessionDescription}. */ +public 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. + private static final Pattern ATTRIBUTE_PATTERN = Pattern.compile("([0-9A-Za-z-]+)(?::(.*))?"); + // SDP media description line: + // 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 CRLF = "\r\n"; + + 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 : Util.split(sdpString, CRLF)) { + if ("".equals(line)) { + continue; + } + + Matcher matcher = SDP_LINE_PATTERN.matcher(line); + if (!matcher.matches()) { + throw new ParserException("Malformed SDP line: " + line); + } + + String sdpType = checkNotNull(matcher.group(1)); + String sdpValue = checkNotNull(matcher.group(2)); + + switch (sdpType) { + case VERSION_TYPE: + if (!SUPPORTED_SDP_VERSION.equals(sdpValue)) { + throw new ParserException(String.format("SDP version %s is not supported.", sdpValue)); + } + 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 new ParserException("Malformed Attribute line: " + line); + } + + 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 (IllegalStateException e) { + throw new ParserException(e); + } + } + + private static void addMediaDescriptionToSession( + SessionDescription.Builder sessionDescriptionBuilder, + MediaDescription.Builder mediaDescriptionBuilder) + throws ParserException { + try { + sessionDescriptionBuilder.addMediaDescription(mediaDescriptionBuilder.build()); + } catch (IllegalStateException e) { + throw new ParserException(e); + } + } + + private static MediaDescription.Builder parseMediaDescriptionLine(String line) + throws ParserException { + Matcher matcher = MEDIA_DESCRIPTION_PATTERN.matcher(line); + if (!matcher.matches()) { + throw new ParserException("Malformed SDP media description line: " + line); + } + 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 new ParserException("Malformed SDP media description line: " + line, e); + } + } + + /** Prevents initialization. */ + private SessionDescriptionParser() {} +} diff --git a/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/sdp/package-info.java b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/sdp/package-info.java new file mode 100644 index 0000000000..98f34fee50 --- /dev/null +++ b/library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/sdp/package-info.java @@ -0,0 +1,19 @@ +/* + * 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. + */ +@NonNullApi +package com.google.android.exoplayer2.source.rtsp.sdp; + +import com.google.android.exoplayer2.util.NonNullApi; diff --git a/library/rtsp/src/test/AndroidManifest.xml b/library/rtsp/src/test/AndroidManifest.xml new file mode 100644 index 0000000000..3f161357fc --- /dev/null +++ b/library/rtsp/src/test/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspClientTest.java b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspClientTest.java new file mode 100644 index 0000000000..637f760113 --- /dev/null +++ b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspClientTest.java @@ -0,0 +1,78 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import android.net.Uri; +import androidx.annotation.Nullable; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.robolectric.RobolectricUtil; +import com.google.android.exoplayer2.source.rtsp.RtspClient.SessionInfoListener; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import java.util.concurrent.atomic.AtomicBoolean; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests the {@link RtspClient} using the {@link RtspServer}. */ +@RunWith(AndroidJUnit4.class) +public final class RtspClientTest { + + private @MonotonicNonNull RtspClient rtspClient; + private @MonotonicNonNull RtspServer rtspServer; + + @Before + public void setUp() { + rtspServer = new RtspServer(); + } + + @After + public void tearDown() { + Util.closeQuietly(rtspServer); + Util.closeQuietly(rtspClient); + } + + @Test + public void connectServerAndClient_withServerSupportsOnlyOptions_sessionTimelineRequestFails() + throws Exception { + int serverRtspPortNumber = checkNotNull(rtspServer).startAndGetPortNumber(); + + AtomicBoolean sessionTimelineUpdateEventReceived = new AtomicBoolean(); + rtspClient = + new RtspClient( + new SessionInfoListener() { + @Override + public void onSessionTimelineUpdated( + RtspSessionTiming timing, ImmutableList tracks) {} + + @Override + public void onSessionTimelineRequestFailed( + String message, @Nullable Throwable cause) { + sessionTimelineUpdateEventReceived.set(true); + } + }, + /* userAgent= */ "ExoPlayer:RtspClientTest", + /* uri= */ Uri.parse( + Util.formatInvariant("rtsp://localhost:%d/test", serverRtspPortNumber))); + rtspClient.start(); + + RobolectricUtil.runMainLooperUntil(sessionTimelineUpdateEventReceived::get); + } +} diff --git a/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspMediaPeriodTest.java b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspMediaPeriodTest.java new file mode 100644 index 0000000000..9fa8a17010 --- /dev/null +++ b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspMediaPeriodTest.java @@ -0,0 +1,102 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp; + +import static com.google.android.exoplayer2.robolectric.RobolectricUtil.runMainLooperUntil; +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import androidx.annotation.Nullable; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.rtsp.sdp.MediaDescription; +import com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription; +import com.google.android.exoplayer2.upstream.DefaultAllocator; +import com.google.common.collect.ImmutableList; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link RtspMediaPeriod}. */ +@RunWith(AndroidJUnit4.class) +public class RtspMediaPeriodTest { + + private static final RtspClient PLACEHOLDER_RTSP_CLIENT = + new RtspClient( + new RtspClient.SessionInfoListener() { + @Override + public void onSessionTimelineUpdated( + RtspSessionTiming timing, ImmutableList tracks) {} + @Override + public void onSessionTimelineRequestFailed(String message, @Nullable Throwable cause) {} + }, + /* userAgent= */ null, + Uri.EMPTY); + + @Test + public void prepare_startsLoading() throws Exception { + RtspMediaPeriod rtspMediaPeriod = + new RtspMediaPeriod( + new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE), + ImmutableList.of( + new RtspMediaTrack( + new MediaDescription.Builder( + /* mediaType= */ MediaDescription.MEDIA_TYPE_VIDEO, + /* port= */ 0, + /* transportProtocol= */ MediaDescription.RTP_AVP_PROFILE, + /* payloadType= */ 96) + .setConnection("IN IP4 0.0.0.0") + .setBitrate(500_000) + .addAttribute(SessionDescription.ATTR_RTPMAP, "96 H264/90000") + .addAttribute( + SessionDescription.ATTR_FMTP, + "96 packetization-mode=1;profile-level-id=64001F;sprop-parameter-sets=Z2QAH6zZQPARabIAAAMACAAAAwGcHjBjLA==,aOvjyyLA") + .addAttribute(SessionDescription.ATTR_CONTROL, "track1") + .build(), + Uri.parse("rtsp://localhost/test"))), + PLACEHOLDER_RTSP_CLIENT); + + AtomicBoolean prepareCallbackCalled = new AtomicBoolean(false); + rtspMediaPeriod.prepare( + new MediaPeriod.Callback() { + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + prepareCallbackCalled.set(true); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod source) { + source.continueLoading(/* positionUs= */ 0); + } + }, + /* positionUs= */ 0); + + runMainLooperUntil(prepareCallbackCalled::get); + rtspMediaPeriod.release(); + } + + @Test + public void getBufferedPositionUs_withNoRtspMediaTracks_returnsEndOfSource() { + RtspMediaPeriod rtspMediaPeriod = + new RtspMediaPeriod( + new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE), + ImmutableList.of(), + PLACEHOLDER_RTSP_CLIENT); + + assertThat(rtspMediaPeriod.getBufferedPositionUs()).isEqualTo(C.TIME_END_OF_SOURCE); + } +} diff --git a/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspMediaTrackTest.java b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspMediaTrackTest.java new file mode 100644 index 0000000000..5020b087dc --- /dev/null +++ b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspMediaTrackTest.java @@ -0,0 +1,260 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp; + +import static com.google.android.exoplayer2.source.rtsp.sdp.MediaDescription.MEDIA_TYPE_AUDIO; +import static com.google.android.exoplayer2.source.rtsp.sdp.MediaDescription.MEDIA_TYPE_VIDEO; +import static com.google.android.exoplayer2.source.rtsp.sdp.MediaDescription.RTP_AVP_PROFILE; +import static com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription.ATTR_CONTROL; +import static com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription.ATTR_FMTP; +import static com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription.ATTR_RTPMAP; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.audio.AacUtil; +import com.google.android.exoplayer2.source.rtsp.rtp.RtpPayloadFormat; +import com.google.android.exoplayer2.source.rtsp.sdp.MediaDescription; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link RtspMediaTrack}. */ +@RunWith(AndroidJUnit4.class) +public class RtspMediaTrackTest { + + @Test + public void generatePayloadFormat_withH264MediaDescription_succeeds() throws Exception { + MediaDescription mediaDescription = + new MediaDescription.Builder(MEDIA_TYPE_VIDEO, 0, RTP_AVP_PROFILE, 96) + .setConnection("IN IP4 0.0.0.0") + .setBitrate(500_000) + .addAttribute(ATTR_RTPMAP, "96 H264/90000") + .addAttribute( + ATTR_FMTP, + "96 packetization-mode=1;profile-level-id=64001F;sprop-parameter-sets=Z2QAH6zZQPARabIAAAMACAAAAwGcHjBjLA==,aOvjyyLA") + .addAttribute(ATTR_CONTROL, "track1") + .build(); + + RtpPayloadFormat format = RtspMediaTrack.generatePayloadFormat(mediaDescription); + RtpPayloadFormat expectedFormat = + new RtpPayloadFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_H264) + .setAverageBitrate(500_000) + .setPixelWidthHeightRatio(1.0f) + .setHeight(544) + .setWidth(960) + .setCodecs("avc1.64001F") + .setInitializationData( + ImmutableList.of( + new byte[] { + 0, 0, 0, 1, 103, 100, 0, 31, -84, -39, 64, -16, 17, 105, -78, 0, 0, 3, 0, + 8, 0, 0, 3, 1, -100, 30, 48, 99, 44 + }, + new byte[] {0, 0, 0, 1, 104, -21, -29, -53, 34, -64})) + .build(), + /* rtpPayloadType= */ 96, + /* clockRate= */ 90_000, + /* fmtpParameters= */ ImmutableMap.of( + "packetization-mode", "1", + "profile-level-id", "64001F", + "sprop-parameter-sets", "Z2QAH6zZQPARabIAAAMACAAAAwGcHjBjLA==,aOvjyyLA")); + + assertThat(format).isEqualTo(expectedFormat); + } + + @Test + public void generatePayloadFormat_withAacMediaDescription_succeeds() throws Exception { + MediaDescription mediaDescription = + new MediaDescription.Builder(MEDIA_TYPE_AUDIO, 0, RTP_AVP_PROFILE, 97) + .setConnection("IN IP4 0.0.0.0") + .setBitrate(96_000) + .addAttribute(ATTR_RTPMAP, "97 MPEG4-GENERIC/44100") + .addAttribute( + ATTR_FMTP, + "97 streamtype=5; profile-level-id=1; mode=AAC-hbr; sizelength=13; indexlength=3;" + + " indexdeltalength=3; config=1208") + .addAttribute(ATTR_CONTROL, "track2") + .build(); + + RtpPayloadFormat format = RtspMediaTrack.generatePayloadFormat(mediaDescription); + RtpPayloadFormat expectedFormat = + new RtpPayloadFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_AAC) + .setChannelCount(1) + .setSampleRate(44100) + .setAverageBitrate(96_000) + .setCodecs("mp4a.40.1") + .setInitializationData( + ImmutableList.of( + AacUtil.buildAacLcAudioSpecificConfig( + /* sampleRate= */ 44_100, /* channelCount= */ 1))) + .build(), + /* rtpPayloadType= */ 97, + /* clockRate= */ 44_100, + /* fmtpParameters= */ new ImmutableMap.Builder() + .put("streamtype", "5") + .put("profile-level-id", "1") + .put("mode", "AAC-hbr") + .put("sizelength", "13") + .put("indexlength", "3") + .put("indexdeltalength", "3") + .put("config", "1208") + .build()); + + assertThat(format).isEqualTo(expectedFormat); + } + + @Test + public void generatePayloadFormat_withAc3MediaDescriptionWithDefaultChannelCount_succeeds() + throws Exception { + MediaDescription mediaDescription = + new MediaDescription.Builder(MEDIA_TYPE_AUDIO, 0, RTP_AVP_PROFILE, 97) + .setConnection("IN IP4 0.0.0.0") + .setBitrate(48_000) + .addAttribute(ATTR_RTPMAP, "97 AC3/48000") + .addAttribute(ATTR_CONTROL, "track2") + .build(); + + RtpPayloadFormat format = RtspMediaTrack.generatePayloadFormat(mediaDescription); + RtpPayloadFormat expectedFormat = + new RtpPayloadFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_AC3) + .setChannelCount(6) + .setSampleRate(48000) + .setAverageBitrate(48_000) + .build(), + /* rtpPayloadType= */ 97, + /* clockRate= */ 48000, + /* fmtpParameters= */ ImmutableMap.of()); + + assertThat(format).isEqualTo(expectedFormat); + } + + @Test + public void generatePayloadFormat_withAc3MediaDescription_succeeds() throws Exception { + MediaDescription mediaDescription = + new MediaDescription.Builder(MEDIA_TYPE_AUDIO, 0, RTP_AVP_PROFILE, 97) + .setConnection("IN IP4 0.0.0.0") + .setBitrate(48_000) + .addAttribute(ATTR_RTPMAP, "97 AC3/48000/2") + .addAttribute(ATTR_CONTROL, "track2") + .build(); + + RtpPayloadFormat format = RtspMediaTrack.generatePayloadFormat(mediaDescription); + RtpPayloadFormat expectedFormat = + new RtpPayloadFormat( + new Format.Builder() + .setSampleMimeType(MimeTypes.AUDIO_AC3) + .setChannelCount(2) + .setSampleRate(48000) + .setAverageBitrate(48_000) + .build(), + /* rtpPayloadType= */ 97, + /* clockRate= */ 48000, + /* fmtpParameters= */ ImmutableMap.of()); + + assertThat(format).isEqualTo(expectedFormat); + } + + @Test + public void + generatePayloadFormat_withAacMediaDescriptionMissingFmtpAttribute_throwsIllegalArgumentException() { + MediaDescription mediaDescription = + new MediaDescription.Builder(MEDIA_TYPE_AUDIO, 0, RTP_AVP_PROFILE, 97) + .setConnection("IN IP4 0.0.0.0") + .setBitrate(96_000) + .addAttribute(ATTR_RTPMAP, "97 MPEG4-GENERIC/44100") + .addAttribute(ATTR_CONTROL, "track2") + .build(); + assertThrows( + IllegalArgumentException.class, + () -> RtspMediaTrack.generatePayloadFormat(mediaDescription)); + } + + @Test + public void + generatePayloadFormat_withMediaDescriptionMissingProfileLevel_throwsIllegalArgumentException() { + MediaDescription mediaDescription = + new MediaDescription.Builder(MEDIA_TYPE_AUDIO, 0, RTP_AVP_PROFILE, 97) + .setConnection("IN IP4 0.0.0.0") + .setBitrate(96_000) + .addAttribute(ATTR_RTPMAP, "97 MPEG4-GENERIC/44100") + .addAttribute( + ATTR_FMTP, + "97 streamtype=5;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1208") + .addAttribute(ATTR_CONTROL, "track2") + .build(); + assertThrows( + IllegalArgumentException.class, + () -> RtspMediaTrack.generatePayloadFormat(mediaDescription)); + } + + @Test + public void + generatePayloadFormat_withH264MediaDescriptionMissingFmtpAttribute_throwsIllegalArgumentException() { + MediaDescription mediaDescription = + new MediaDescription.Builder(MEDIA_TYPE_VIDEO, 0, RTP_AVP_PROFILE, 96) + .setConnection("IN IP4 0.0.0.0") + .setBitrate(500_000) + .addAttribute(ATTR_RTPMAP, "96 H264/90000") + .addAttribute(ATTR_CONTROL, "track1") + .build(); + assertThrows( + IllegalArgumentException.class, + () -> RtspMediaTrack.generatePayloadFormat(mediaDescription)); + } + + @Test + public void + generatePayloadFormat_withH264MediaDescriptionMissingProfileLevel_throwsIllegalArgumentException() { + MediaDescription mediaDescription = + new MediaDescription.Builder(MEDIA_TYPE_VIDEO, 0, RTP_AVP_PROFILE, 96) + .setConnection("IN IP4 0.0.0.0") + .setBitrate(500_000) + .addAttribute(ATTR_RTPMAP, "96 H264/90000") + .addAttribute( + ATTR_FMTP, + "96 packetization-mode=1;sprop-parameter-sets=Z2QAH6zZQPARabIAAAMACAAAAwGcHjBjLA==,aOvjyyLA") + .addAttribute(ATTR_CONTROL, "track1") + .build(); + assertThrows( + IllegalArgumentException.class, + () -> RtspMediaTrack.generatePayloadFormat(mediaDescription)); + } + + @Test + public void + generatePayloadFormat_withH264MediaDescriptionMissingSpropParameter_throwsIllegalArgumentException() { + MediaDescription mediaDescription = + new MediaDescription.Builder(MEDIA_TYPE_VIDEO, 0, RTP_AVP_PROFILE, 96) + .setConnection("IN IP4 0.0.0.0") + .setBitrate(500_000) + .addAttribute(ATTR_RTPMAP, "96 H264/90000") + .addAttribute(ATTR_FMTP, "96 packetization-mode=1;profile-level-id=64001F") + .addAttribute(ATTR_CONTROL, "track1") + .build(); + assertThrows( + IllegalArgumentException.class, + () -> RtspMediaTrack.generatePayloadFormat(mediaDescription)); + } +} diff --git a/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspServer.java b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspServer.java new file mode 100644 index 0000000000..e61864011b --- /dev/null +++ b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspServer.java @@ -0,0 +1,155 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp; + +import static com.google.android.exoplayer2.source.rtsp.message.RtspRequest.METHOD_OPTIONS; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import android.os.Handler; +import android.os.Looper; +import com.google.android.exoplayer2.source.rtsp.message.RtspHeaders; +import com.google.android.exoplayer2.source.rtsp.message.RtspMessageChannel; +import com.google.android.exoplayer2.source.rtsp.message.RtspMessageUtil; +import com.google.android.exoplayer2.source.rtsp.message.RtspRequest; +import com.google.android.exoplayer2.source.rtsp.message.RtspResponse; +import com.google.android.exoplayer2.util.Util; +import java.io.Closeable; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.util.List; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** The RTSP server. */ +public final class RtspServer implements Closeable { + + private static final String PUBLIC_SUPPORTED_METHODS = "OPTIONS"; + /** RTSP error Method Not Allowed (RFC2326 Section 7.1.1). */ + private static final int STATUS_METHOD_NOT_ALLOWED = 405; + + private final Thread listenerThread; + /** Runs on the thread on which the constructor was called. */ + private final Handler mainHandler; + + private @MonotonicNonNull ServerSocket serverSocket; + private @MonotonicNonNull RtspMessageChannel connectedClient; + + private volatile boolean isCanceled; + + /** + * Creates a new instance. + * + *

The constructor must be called on a {@link Looper} thread. + */ + public RtspServer() { + listenerThread = + new Thread(this::listenToIncomingRtspConnection, "ExoPlayerTest:RtspConnectionMonitor"); + mainHandler = Util.createHandlerForCurrentLooper(); + } + + /** + * Starts the server. The server starts listening to incoming RTSP connections. + * + *

The user must call {@link #close} if {@link IOException} is thrown. Closed instances must + * not be started again. + * + * @return The server side port number for RTSP connections. + */ + public int startAndGetPortNumber() throws IOException { + // Auto assign port and allow only one client connection (backlog). + serverSocket = + new ServerSocket(/* port= */ 0, /* backlog= */ 1, InetAddress.getByName(/* host= */ null)); + listenerThread.start(); + return serverSocket.getLocalPort(); + } + + @Override + public void close() throws IOException { + isCanceled = true; + if (serverSocket != null) { + serverSocket.close(); + } + if (connectedClient != null) { + connectedClient.close(); + } + } + + private void handleNewClientConnected(Socket socket) { + try { + connectedClient = new RtspMessageChannel(new MessageListener()); + connectedClient.openSocket(socket); + } catch (IOException e) { + Util.closeQuietly(connectedClient); + // Log the error. + e.printStackTrace(); + } + } + + private final class MessageListener implements RtspMessageChannel.MessageListener { + @Override + public void onRtspMessageReceived(List message) { + RtspRequest request = RtspMessageUtil.parseRequest(message); + switch (request.method) { + case METHOD_OPTIONS: + onOptionsRequestReceived(request); + break; + default: + connectedClient.send( + RtspMessageUtil.serializeResponse( + new RtspResponse( + /* status= */ STATUS_METHOD_NOT_ALLOWED, + /* headers= */ new RtspHeaders.Builder() + .add( + RtspHeaders.CSEQ, checkNotNull(request.headers.get(RtspHeaders.CSEQ))) + .build(), + /* messageBody= */ ""))); + } + } + + private void onOptionsRequestReceived(RtspRequest request) { + connectedClient.send( + RtspMessageUtil.serializeResponse( + new RtspResponse( + /* status= */ 200, + /* headers= */ new RtspHeaders.Builder() + .add(RtspHeaders.CSEQ, checkNotNull(request.headers.get(RtspHeaders.CSEQ))) + .add(RtspHeaders.PUBLIC, PUBLIC_SUPPORTED_METHODS) + .build(), + /* messageBody= */ ""))); + } + } + + private void listenToIncomingRtspConnection() { + while (!isCanceled) { + try { + Socket acceptedClientSocket = serverSocket.accept(); + mainHandler.post(() -> handleNewClientConnected(acceptedClientSocket)); + } catch (SocketException e) { + // SocketException is thrown when serverSocket is closed while running accept(). + if (!isCanceled) { + isCanceled = true; + e.printStackTrace(); + } + } catch (IOException e) { + isCanceled = true; + // Log the error. + e.printStackTrace(); + } + } + } +} diff --git a/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspSessionTimingTest.java b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspSessionTimingTest.java new file mode 100644 index 0000000000..133b4155b7 --- /dev/null +++ b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspSessionTimingTest.java @@ -0,0 +1,62 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link RtspSessionTiming}. */ +@RunWith(AndroidJUnit4.class) +public class RtspSessionTimingTest { + @Test + public void parseTiming_withNowLiveTiming() throws Exception { + RtspSessionTiming sessionTiming = RtspSessionTiming.parseTiming("npt=now-"); + assertThat(sessionTiming.getDurationMs()).isEqualTo(C.TIME_UNSET); + assertThat(sessionTiming.isLive()).isTrue(); + } + + @Test + public void parseTiming_withZeroLiveTiming() throws Exception { + RtspSessionTiming sessionTiming = RtspSessionTiming.parseTiming("npt=0-"); + assertThat(sessionTiming.getDurationMs()).isEqualTo(C.TIME_UNSET); + assertThat(sessionTiming.isLive()).isTrue(); + } + + @Test + public void parseTiming_withDecimalZeroLiveTiming() throws Exception { + RtspSessionTiming sessionTiming = RtspSessionTiming.parseTiming("npt=0.000-"); + assertThat(sessionTiming.getDurationMs()).isEqualTo(C.TIME_UNSET); + assertThat(sessionTiming.isLive()).isTrue(); + } + + @Test + public void parseTiming_withRangeTiming() throws Exception { + RtspSessionTiming sessionTiming = RtspSessionTiming.parseTiming("npt=0.000-32.054"); + assertThat(sessionTiming.getDurationMs()).isEqualTo(32054); + assertThat(sessionTiming.isLive()).isFalse(); + } + + @Test + public void parseTiming_withInvalidRangeTiming_throwsIllegalArgumentException() { + assertThrows( + IllegalArgumentException.class, () -> RtspSessionTiming.parseTiming("npt=10.000-2.054")); + } +} diff --git a/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspTrackTimingTest.java b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspTrackTimingTest.java new file mode 100644 index 0000000000..1269811301 --- /dev/null +++ b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/RtspTrackTimingTest.java @@ -0,0 +1,93 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import android.net.Uri; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ParserException; +import com.google.common.collect.ImmutableList; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link RtspTrackTiming}. */ +@RunWith(AndroidJUnit4.class) +public class RtspTrackTimingTest { + @Test + public void parseTiming_withSeqNumberAndRtpTime() throws Exception { + String rtpInfoString = + "url=rtsp://video.example.com/twister/video;seq=12312232;rtptime=78712811"; + + ImmutableList trackTimingList = + RtspTrackTiming.parseTrackTiming(rtpInfoString); + + assertThat(trackTimingList).hasSize(1); + RtspTrackTiming trackTiming = trackTimingList.get(0); + assertThat(trackTiming.uri).isEqualTo(Uri.parse("rtsp://video.example.com/twister/video")); + assertThat(trackTiming.sequenceNumber).isEqualTo(12312232); + assertThat(trackTiming.rtpTimestamp).isEqualTo(78712811); + } + + @Test + public void parseTiming_withSeqNumberOnly() throws Exception { + String rtpInfoString = + "url=rtsp://foo.com/bar.avi/streamid=0;seq=45102,url=rtsp://foo.com/bar.avi/streamid=1;seq=30211"; + + ImmutableList trackTimingList = + RtspTrackTiming.parseTrackTiming(rtpInfoString); + + assertThat(trackTimingList).hasSize(2); + RtspTrackTiming trackTiming = trackTimingList.get(0); + assertThat(trackTiming.uri).isEqualTo(Uri.parse("rtsp://foo.com/bar.avi/streamid=0")); + assertThat(trackTiming.sequenceNumber).isEqualTo(45102); + assertThat(trackTiming.rtpTimestamp).isEqualTo(C.TIME_UNSET); + trackTiming = trackTimingList.get(1); + assertThat(trackTiming.uri).isEqualTo(Uri.parse("rtsp://foo.com/bar.avi/streamid=1")); + assertThat(trackTiming.sequenceNumber).isEqualTo(30211); + assertThat(trackTiming.rtpTimestamp).isEqualTo(C.TIME_UNSET); + } + + @Test + public void parseTiming_withInvalidParameter_throws() { + String rtpInfoString = "url=rtsp://video.example.com/twister/video;seq=123abc"; + + assertThrows(ParserException.class, () -> RtspTrackTiming.parseTrackTiming(rtpInfoString)); + } + + @Test + public void parseTiming_withInvalidUrl_throws() { + String rtpInfoString = "url=video.example.com/twister/video;seq=36192348"; + + assertThrows(ParserException.class, () -> RtspTrackTiming.parseTrackTiming(rtpInfoString)); + } + + @Test + public void parseTiming_withNoParameter_throws() { + String rtpInfoString = "url=rtsp://video.example.com/twister/video"; + + assertThrows(ParserException.class, () -> RtspTrackTiming.parseTrackTiming(rtpInfoString)); + } + + @Test + public void parseTiming_withNoUrl_throws() { + String rtpInfoString = "seq=35421887"; + + assertThrows(ParserException.class, () -> RtspTrackTiming.parseTrackTiming(rtpInfoString)); + } +} diff --git a/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/message/RtspHeadersTest.java b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/message/RtspHeadersTest.java new file mode 100644 index 0000000000..69eaef7c4d --- /dev/null +++ b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/message/RtspHeadersTest.java @@ -0,0 +1,102 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.message; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link RtspHeaders}. */ +@RunWith(AndroidJUnit4.class) +public final class RtspHeadersTest { + + @Test + public void build_withHeaderLines() { + RtspHeaders headers = + new RtspHeaders.Builder() + .addAll( + ImmutableList.of( + "Accept: application/sdp ", // Extra space after header value. + "CSeq:3", // No space after colon. + "Content-Length: 707", + "Transport: RTP/AVP;unicast;client_port=65458-65459\r\n")) + .build(); + + assertThat(headers.get("Accept")).isEqualTo("application/sdp"); + assertThat(headers.get("CSeq")).isEqualTo("3"); + assertThat(headers.get("Content-Length")).isEqualTo("707"); + assertThat(headers.get("Transport")).isEqualTo("RTP/AVP;unicast;client_port=65458-65459"); + } + + @Test + public void build_withHeaderLinesAsMap() { + RtspHeaders headers = + new RtspHeaders.Builder() + .addAll( + ImmutableMap.of( + "Accept", " application/sdp ", // Extra space after header value. + "CSeq", "3", // No space after colon. + "Content-Length", "707", + "Transport", "RTP/AVP;unicast;client_port=65458-65459\r\n")) + .build(); + + assertThat(headers.get("Accept")).isEqualTo("application/sdp"); + assertThat(headers.get("CSeq")).isEqualTo("3"); + assertThat(headers.get("Content-Length")).isEqualTo("707"); + assertThat(headers.get("Transport")).isEqualTo("RTP/AVP;unicast;client_port=65458-65459"); + } + + @Test + public void get_getsHeaderValuesCaseInsensitively() { + RtspHeaders headers = + new RtspHeaders.Builder() + .addAll( + ImmutableList.of( + "ACCEPT: application/sdp ", // Extra space after header value. + "Cseq:3", // No space after colon. + "Content-LENGTH: 707", + "transport: RTP/AVP;unicast;client_port=65458-65459\r\n")) + .build(); + + assertThat(headers.get("Accept")).isEqualTo("application/sdp"); + assertThat(headers.get("CSeq")).isEqualTo("3"); + assertThat(headers.get("Content-Length")).isEqualTo("707"); + assertThat(headers.get("Transport")).isEqualTo("RTP/AVP;unicast;client_port=65458-65459"); + } + + @Test + public void asMap() { + RtspHeaders headers = + new RtspHeaders.Builder() + .addAll( + ImmutableList.of( + "Accept: application/sdp ", // Extra space after header value. + "CSeq:3", // No space after colon. + "Content-Length: 707", + "Transport: RTP/AVP;unicast;client_port=65458-65459\r\n")) + .build(); + assertThat(headers.asMap()) + .containsExactly( + "Accept", "application/sdp", + "CSeq", "3", + "Content-Length", "707", + "Transport", "RTP/AVP;unicast;client_port=65458-65459"); + } +} diff --git a/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/message/RtspMessageUtilTest.java b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/message/RtspMessageUtilTest.java new file mode 100644 index 0000000000..84f2e1d704 --- /dev/null +++ b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/message/RtspMessageUtilTest.java @@ -0,0 +1,366 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.message; + +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.base.Charsets; +import com.google.common.collect.ImmutableMap; +import java.util.Arrays; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link RtspMessageUtil}. */ +@RunWith(AndroidJUnit4.class) +public final class RtspMessageUtilTest { + + @Test + public void parseRequest_withOptionsRequest_succeeds() { + List requestLines = + Arrays.asList( + "OPTIONS rtsp://localhost:554/foo.bar RTSP/1.0", + "CSeq: 2", + "User-Agent: LibVLC/3.0.11", + ""); + RtspRequest request = RtspMessageUtil.parseRequest(requestLines); + + assertThat(request.method).isEqualTo(RtspRequest.METHOD_OPTIONS); + assertThat(request.headers.asMap()) + .containsExactly(RtspHeaders.CSEQ, "2", RtspHeaders.USER_AGENT, "LibVLC/3.0.11"); + assertThat(request.messageBody).isEmpty(); + } + + @Test + public void parseResponse_withOptionsResponse_succeeds() { + List responseLines = + Arrays.asList( + "RTSP/1.0 200 OK", + "CSeq: 2", + "Public: OPTIONS, DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE, GET_PARAMETER, SET_PARAMETER", + ""); + RtspResponse response = RtspMessageUtil.parseResponse(responseLines); + + assertThat(response.status).isEqualTo(200); + assertThat(response.headers.asMap()) + .containsExactly( + RtspHeaders.CSEQ, + "2", + RtspHeaders.PUBLIC, + "OPTIONS, DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE, GET_PARAMETER, SET_PARAMETER"); + assertThat(response.messageBody).isEmpty(); + } + + @Test + public void parseRequest_withDescribeRequest_succeeds() { + List requestLines = + Arrays.asList( + "DESCRIBE rtsp://localhost:554/foo.bar RTSP/1.0", + "CSeq: 3", + "User-Agent: LibVLC/3.0.11", + "Accept: application/sdp", + ""); + RtspRequest request = RtspMessageUtil.parseRequest(requestLines); + + assertThat(request.method).isEqualTo(RtspRequest.METHOD_DESCRIBE); + assertThat(request.headers.asMap()) + .containsExactly( + RtspHeaders.CSEQ, + "3", + RtspHeaders.USER_AGENT, + "LibVLC/3.0.11", + RtspHeaders.ACCEPT, + "application/sdp"); + assertThat(request.messageBody).isEmpty(); + } + + @Test + public void parseResponse_withDescribeResponse_succeeds() { + List responseLines = + Arrays.asList( + "RTSP/1.0 200 OK", + "CSeq: 3", + "Content-Base: rtsp://127.0.0.1/test.mkv/", + "Content-Type: application/sdp", + "Content-Length: 707", + "", + "v=0", + "o=- 1606776316530225 1 IN IP4 192.168.2.176", + "i=imax.mkv", + "m=video 0 RTP/AVP 96", + "a=rtpmap:96 H264/90000", + "a=control:track1", + "m=audio 0 RTP/AVP 97", + "a=rtpmap:97 AC3/48000", + "a=control:track2"); + RtspResponse response = RtspMessageUtil.parseResponse(responseLines); + + assertThat(response.status).isEqualTo(200); + assertThat(response.headers.asMap()) + .containsExactly( + RtspHeaders.CSEQ, + "3", + RtspHeaders.CONTENT_BASE, + "rtsp://127.0.0.1/test.mkv/", + RtspHeaders.CONTENT_TYPE, + "application/sdp", + RtspHeaders.CONTENT_LENGTH, + "707"); + + assertThat(response.messageBody) + .isEqualTo( + "v=0\r\n" + + "o=- 1606776316530225 1 IN IP4 192.168.2.176\r\n" + + "i=imax.mkv\r\n" + + "m=video 0 RTP/AVP 96\r\n" + + "a=rtpmap:96 H264/90000\r\n" + + "a=control:track1\r\n" + + "m=audio 0 RTP/AVP 97\r\n" + + "a=rtpmap:97 AC3/48000\r\n" + + "a=control:track2"); + } + + @Test + public void parseRequest_withSetParameterRequest_succeeds() { + List requestLines = + Arrays.asList( + "SET_PARAMETER rtsp://localhost:554/foo.bar RTSP/1.0", + "CSeq: 3", + "User-Agent: LibVLC/3.0.11", + "Content-Length: 20", + "Content-Type: text/parameters", + "", + "param: stuff"); + RtspRequest request = RtspMessageUtil.parseRequest(requestLines); + + assertThat(request.method).isEqualTo(RtspRequest.METHOD_SET_PARAMETER); + assertThat(request.headers.asMap()) + .containsExactly( + RtspHeaders.CSEQ, + "3", + RtspHeaders.USER_AGENT, + "LibVLC/3.0.11", + RtspHeaders.CONTENT_LENGTH, + "20", + RtspHeaders.CONTENT_TYPE, + "text/parameters"); + assertThat(request.messageBody).isEqualTo("param: stuff"); + } + + @Test + public void parseResponse_withGetParameterResponse_succeeds() { + List responseLines = + Arrays.asList( + "RTSP/1.0 200 OK", + "CSeq: 431", + "Content-Length: 46", + "Content-Type: text/parameters", + "", + "packets_received: 10", + "jitter: 0.3838"); + RtspResponse response = RtspMessageUtil.parseResponse(responseLines); + + assertThat(response.status).isEqualTo(200); + assertThat(response.headers.asMap()) + .containsExactly( + RtspHeaders.CSEQ, + "431", + RtspHeaders.CONTENT_LENGTH, + "46", + RtspHeaders.CONTENT_TYPE, + "text/parameters"); + + assertThat(response.messageBody).isEqualTo("packets_received: 10\r\n" + "jitter: 0.3838"); + } + + @Test + public void serialize_setupRequest_succeeds() { + RtspRequest request = + new RtspRequest( + Uri.parse("rtsp://127.0.0.1/test.mkv/track1"), + RtspRequest.METHOD_SETUP, + new RtspHeaders.Builder() + .addAll( + ImmutableMap.of( + RtspHeaders.CSEQ, "4", + RtspHeaders.TRANSPORT, "RTP/AVP;unicast;client_port=65458-65459")) + .build(), + /* messageBody= */ ""); + List messageLines = RtspMessageUtil.serializeRequest(request); + + List expectedLines = + Arrays.asList( + "SETUP rtsp://127.0.0.1/test.mkv/track1 RTSP/1.0", + "CSeq: 4", + "Transport: RTP/AVP;unicast;client_port=65458-65459", + "", + ""); + String expectedRtspMessage = + "SETUP rtsp://127.0.0.1/test.mkv/track1 RTSP/1.0\r\n" + + "CSeq: 4\r\n" + + "Transport: RTP/AVP;unicast;client_port=65458-65459\r\n" + + "\r\n"; + + assertThat(messageLines).isEqualTo(expectedLines); + assertThat(RtspMessageUtil.convertMessageToByteArray(messageLines)) + .isEqualTo(expectedRtspMessage.getBytes(Charsets.UTF_8)); + } + + @Test + public void serialize_setupResponse_succeeds() { + RtspResponse response = + new RtspResponse( + /* status= */ 200, + new RtspHeaders.Builder() + .addAll( + ImmutableMap.of( + RtspHeaders.CSEQ, + "4", + RtspHeaders.TRANSPORT, + "RTP/AVP;unicast;client_port=65458-65459;server_port=5354-5355")) + .build(), + /* messageBody= */ ""); + List messageLines = RtspMessageUtil.serializeResponse(response); + + List expectedLines = + Arrays.asList( + "RTSP/1.0 200 OK", + "CSeq: 4", + "Transport: RTP/AVP;unicast;client_port=65458-65459;server_port=5354-5355", + "", + ""); + String expectedRtspMessage = + "RTSP/1.0 200 OK\r\n" + + "CSeq: 4\r\n" + + "Transport: RTP/AVP;unicast;client_port=65458-65459;server_port=5354-5355\r\n" + + "\r\n"; + assertThat(messageLines).isEqualTo(expectedLines); + assertThat(RtspMessageUtil.convertMessageToByteArray(messageLines)) + .isEqualTo(expectedRtspMessage.getBytes(Charsets.UTF_8)); + } + + @Test + public void serialize_describeResponse_succeeds() { + + RtspResponse response = + new RtspResponse( + /* status= */ 200, + new RtspHeaders.Builder() + .addAll( + ImmutableMap.of( + RtspHeaders.CSEQ, "4", + RtspHeaders.CONTENT_BASE, "rtsp://127.0.0.1/test.mkv/", + RtspHeaders.CONTENT_TYPE, "application/sdp", + RtspHeaders.CONTENT_LENGTH, "707")) + .build(), + /* messageBody= */ "v=0\r\n" + + "o=- 1606776316530225 1 IN IP4 192.168.2.176\r\n" + + "i=test.mkv\r\n" + + "m=video 0 RTP/AVP 96\r\n" + + "a=rtpmap:96 H264/90000\r\n" + + "a=control:track1\r\n" + + "m=audio 0 RTP/AVP 97\r\n" + + "a=rtpmap:97 AC3/48000\r\n" + + "a=control:track2"); + List messageLines = RtspMessageUtil.serializeResponse(response); + + List expectedLines = + Arrays.asList( + "RTSP/1.0 200 OK", + "CSeq: 4", + "Content-Base: rtsp://127.0.0.1/test.mkv/", + "Content-Type: application/sdp", + "Content-Length: 707", + "", + "v=0\r\n" + + "o=- 1606776316530225 1 IN IP4 192.168.2.176\r\n" + + "i=test.mkv\r\n" + + "m=video 0 RTP/AVP 96\r\n" + + "a=rtpmap:96 H264/90000\r\n" + + "a=control:track1\r\n" + + "m=audio 0 RTP/AVP 97\r\n" + + "a=rtpmap:97 AC3/48000\r\n" + + "a=control:track2"); + + String expectedRtspMessage = + "RTSP/1.0 200 OK\r\n" + + "CSeq: 4\r\n" + + "Content-Base: rtsp://127.0.0.1/test.mkv/\r\n" + + "Content-Type: application/sdp\r\n" + + "Content-Length: 707\r\n" + + "\r\n" + + "v=0\r\n" + + "o=- 1606776316530225 1 IN IP4 192.168.2.176\r\n" + + "i=test.mkv\r\n" + + "m=video 0 RTP/AVP 96\r\n" + + "a=rtpmap:96 H264/90000\r\n" + + "a=control:track1\r\n" + + "m=audio 0 RTP/AVP 97\r\n" + + "a=rtpmap:97 AC3/48000\r\n" + + "a=control:track2"; + + assertThat(messageLines).isEqualTo(expectedLines); + assertThat(RtspMessageUtil.convertMessageToByteArray(messageLines)) + .isEqualTo(expectedRtspMessage.getBytes(Charsets.UTF_8)); + } + + @Test + public void serialize_failedResponse_succeeds() { + RtspResponse response = + new RtspResponse( + /* status= */ 454, + new RtspHeaders.Builder().add(RtspHeaders.CSEQ, "4").build(), + /* messageBody= */ ""); + List messageLines = RtspMessageUtil.serializeResponse(response); + + List expectedLines = Arrays.asList("RTSP/1.0 454 Session Not Found", "CSeq: 4", "", ""); + String expectedRtspMessage = "RTSP/1.0 454 Session Not Found\r\n" + "CSeq: 4\r\n" + "\r\n"; + + assertThat(RtspMessageUtil.serializeResponse(response)).isEqualTo(expectedLines); + assertThat(RtspMessageUtil.convertMessageToByteArray(messageLines)) + .isEqualTo(expectedRtspMessage.getBytes(Charsets.UTF_8)); + } + + @Test + public void removeUserInfo_withUserInfo() { + Uri uri = Uri.parse("rtsp://user:pass@foo.bar/foo.mkv"); + assertThat(RtspMessageUtil.removeUserInfo(uri)).isEqualTo(Uri.parse("rtsp://foo.bar/foo.mkv")); + } + + @Test + public void removeUserInfo_withUserInfoAndPortNumber() { + Uri uri = Uri.parse("rtsp://user:pass@foo.bar:5050/foo.mkv"); + assertThat(RtspMessageUtil.removeUserInfo(uri)) + .isEqualTo(Uri.parse("rtsp://foo.bar:5050/foo.mkv")); + } + + @Test + public void removeUserInfo_withEmptyUserInfoAndPortNumber() { + Uri uri = Uri.parse("rtsp://@foo.bar:5050/foo.mkv"); + assertThat(RtspMessageUtil.removeUserInfo(uri)) + .isEqualTo(Uri.parse("rtsp://foo.bar:5050/foo.mkv")); + } + + @Test + public void removeUserInfo_withNoUserInfo() { + Uri uri = Uri.parse("rtsp://foo.bar:5050/foo.mkv"); + assertThat(RtspMessageUtil.removeUserInfo(uri)) + .isEqualTo(Uri.parse("rtsp://foo.bar:5050/foo.mkv")); + } +} diff --git a/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpAc3ReaderTest.java b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpAc3ReaderTest.java new file mode 100644 index 0000000000..0ad4e99f46 --- /dev/null +++ b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpAc3ReaderTest.java @@ -0,0 +1,199 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.rtp; + +import static com.google.android.exoplayer2.util.Util.getBytesFromHexString; +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.when; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.extractor.ExtractorOutput; +import com.google.android.exoplayer2.source.rtsp.rtp.reader.RtpAc3Reader; +import com.google.android.exoplayer2.testutil.FakeTrackOutput; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.common.collect.ImmutableMap; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Unit test for {@link RtpAc3Reader}. */ +@RunWith(AndroidJUnit4.class) +public final class RtpAc3ReaderTest { + + private final RtpPacket frame1fragment1 = + createRtpPacket( + /* timestamp= */ 2599168056L, + /* sequenceNumber= */ 40289, + /* marker= */ false, + /* payloadData= */ getBytesFromHexString("02020102")); + private final RtpPacket frame1fragment2 = + createRtpPacket( + /* timestamp= */ 2599168056L, + /* sequenceNumber= */ 40290, + /* marker= */ true, + /* payloadData= */ getBytesFromHexString("03020304")); + private final RtpPacket frame2fragment1 = + createRtpPacket( + /* timestamp= */ 2599169592L, + /* sequenceNumber= */ 40292, + /* marker= */ false, + /* payloadData= */ getBytesFromHexString("02020506")); + private final RtpPacket frame2fragment2 = + createRtpPacket( + /* timestamp= */ 2599169592L, + /* sequenceNumber= */ 40293, + /* marker= */ true, + /* payloadData= */ getBytesFromHexString("03020708")); + + private static final RtpPayloadFormat AC3_FORMAT = + new RtpPayloadFormat( + new Format.Builder() + .setChannelCount(6) + .setSampleMimeType(MimeTypes.AUDIO_AC3) + .setSampleRate(48_000) + .build(), + /* rtpPayloadType= */ 97, + /* clockRate= */ 48_000, + /* fmtpParameters= */ ImmutableMap.of()); + + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + private ParsableByteArray packetData; + + private RtpAc3Reader ac3Reader; + private FakeTrackOutput trackOutput; + @Mock private ExtractorOutput extractorOutput; + + @Before + public void setUp() { + packetData = new ParsableByteArray(); + trackOutput = new FakeTrackOutput(/* deduplicateConsecutiveFormats= */ true); + when(extractorOutput.track(anyInt(), anyInt())).thenReturn(trackOutput); + ac3Reader = new RtpAc3Reader(AC3_FORMAT); + ac3Reader.createTracks(extractorOutput, /* trackId= */ 0); + } + + @Test + public void consume_allPackets() { + ac3Reader.onReceivingFirstPacket(frame1fragment1.timestamp, frame1fragment1.sequenceNumber); + packetData.reset(frame1fragment1.payloadData); + ac3Reader.consume( + packetData, + frame1fragment1.timestamp, + frame1fragment1.sequenceNumber, + /* isFrameBoundary= */ frame1fragment1.marker); + packetData.reset(frame1fragment2.payloadData); + ac3Reader.consume( + packetData, + frame1fragment2.timestamp, + frame1fragment2.sequenceNumber, + /* isFrameBoundary= */ frame1fragment2.marker); + packetData.reset(frame2fragment1.payloadData); + ac3Reader.consume( + packetData, + frame2fragment1.timestamp, + frame2fragment1.sequenceNumber, + /* isFrameBoundary= */ frame2fragment1.marker); + packetData.reset(frame2fragment2.payloadData); + ac3Reader.consume( + packetData, + frame2fragment2.timestamp, + frame2fragment2.sequenceNumber, + /* isFrameBoundary= */ frame2fragment2.marker); + + assertThat(trackOutput.getSampleCount()).isEqualTo(2); + assertThat(trackOutput.getSampleData(0)).isEqualTo(getBytesFromHexString("01020304")); + assertThat(trackOutput.getSampleTimeUs(0)).isEqualTo(0); + assertThat(trackOutput.getSampleData(1)).isEqualTo(getBytesFromHexString("05060708")); + assertThat(trackOutput.getSampleTimeUs(1)).isEqualTo(32000); + } + + @Test + public void consume_fragmentedFrameMissingFirstFragment() { + // First packet timing information is transmitted over RTSP, not RTP. + ac3Reader.onReceivingFirstPacket(frame1fragment1.timestamp, frame1fragment1.sequenceNumber); + packetData.reset(frame1fragment2.payloadData); + ac3Reader.consume( + packetData, + frame1fragment2.timestamp, + frame1fragment2.sequenceNumber, + /* isFrameBoundary= */ frame1fragment2.marker); + packetData.reset(frame2fragment1.payloadData); + ac3Reader.consume( + packetData, + frame2fragment1.timestamp, + frame2fragment1.sequenceNumber, + /* isFrameBoundary= */ frame2fragment1.marker); + packetData.reset(frame2fragment2.payloadData); + ac3Reader.consume( + packetData, + frame2fragment2.timestamp, + frame2fragment2.sequenceNumber, + /* isFrameBoundary= */ frame2fragment2.marker); + + assertThat(trackOutput.getSampleCount()).isEqualTo(2); + assertThat(trackOutput.getSampleData(0)).isEqualTo(getBytesFromHexString("0304")); + assertThat(trackOutput.getSampleTimeUs(0)).isEqualTo(0); + assertThat(trackOutput.getSampleData(1)).isEqualTo(getBytesFromHexString("05060708")); + assertThat(trackOutput.getSampleTimeUs(1)).isEqualTo(32000); + } + + @Test + public void consume_fragmentedFrameMissingBoundaryFragment() { + ac3Reader.onReceivingFirstPacket(frame1fragment1.timestamp, frame1fragment1.sequenceNumber); + packetData.reset(frame1fragment1.payloadData); + ac3Reader.consume( + packetData, + frame1fragment1.timestamp, + frame1fragment1.sequenceNumber, + /* isFrameBoundary= */ frame1fragment1.marker); + packetData.reset(frame2fragment1.payloadData); + ac3Reader.consume( + packetData, + frame2fragment1.timestamp, + frame2fragment1.sequenceNumber, + /* isFrameBoundary= */ frame2fragment1.marker); + packetData.reset(frame2fragment2.payloadData); + ac3Reader.consume( + packetData, + frame2fragment2.timestamp, + frame2fragment2.sequenceNumber, + /* isFrameBoundary= */ frame2fragment2.marker); + + assertThat(trackOutput.getSampleCount()).isEqualTo(2); + assertThat(trackOutput.getSampleData(0)).isEqualTo(getBytesFromHexString("0102")); + assertThat(trackOutput.getSampleTimeUs(0)).isEqualTo(0); + assertThat(trackOutput.getSampleData(1)).isEqualTo(getBytesFromHexString("05060708")); + assertThat(trackOutput.getSampleTimeUs(1)).isEqualTo(32000); + } + + private static RtpPacket createRtpPacket( + long timestamp, int sequenceNumber, boolean marker, byte[] payloadData) { + return new RtpPacket.Builder() + .setTimestamp((int) timestamp) + .setSequenceNumber(sequenceNumber) + .setMarker(marker) + .setPayloadData(payloadData) + .build(); + } +} diff --git a/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpPacketReorderingQueueTest.java b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpPacketReorderingQueueTest.java new file mode 100644 index 0000000000..ab162eba9b --- /dev/null +++ b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpPacketReorderingQueueTest.java @@ -0,0 +1,213 @@ +/* + * Copyright 2020 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 com.google.android.exoplayer2.source.rtsp.rtp; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link RtpPacketReorderingQueue}. */ +@RunWith(AndroidJUnit4.class) +public class RtpPacketReorderingQueueTest { + + private final RtpPacketReorderingQueue reorderingQueue = new RtpPacketReorderingQueue(); + + @Test + public void poll_emptyQueue_returnsNull() { + assertThat(reorderingQueue.poll(/* cutoffTimestampMs= */ 0)).isNull(); + } + + @Test + public void poll_onlyOnePacketInQueue_returns() { + RtpPacket packet = makePacket(/* sequenceNumber= */ 1); + + // Queue after offering: [1]. + reorderingQueue.offer(packet, /* receivedTimestampMs= */ 0); + assertThat(reorderingQueue.poll(/* cutoffTimestampMs= */ 0)).isEqualTo(packet); + } + + @Test + public void poll_withPacketsEnqueuedInOrder_returnsCorrectPacket() { + RtpPacket packet1 = makePacket(/* sequenceNumber= */ 1); + RtpPacket packet2 = makePacket(/* sequenceNumber= */ 2); + RtpPacket packet3 = makePacket(/* sequenceNumber= */ 3); + + reorderingQueue.offer(packet1, /* receivedTimestampMs= */ 0); + reorderingQueue.offer(packet2, /* receivedTimestampMs= */ 0); + reorderingQueue.offer(packet3, /* receivedTimestampMs= */ 0); + + assertThat(reorderingQueue.poll(/* cutoffTimestampMs= */ 0)).isEqualTo(packet1); + assertThat(reorderingQueue.poll(/* cutoffTimestampMs= */ 0)).isEqualTo(packet2); + assertThat(reorderingQueue.poll(/* cutoffTimestampMs= */ 0)).isEqualTo(packet3); + } + + @Test + public void reset_nonEmptyQueue_resetsQueue() { + RtpPacket packet1 = makePacket(/* sequenceNumber= */ 1); + RtpPacket packet2 = makePacket(/* sequenceNumber= */ 2); + + reorderingQueue.offer(packet1, /* receivedTimestampMs= */ 0); + reorderingQueue.offer(packet2, /* receivedTimestampMs= */ 0); + reorderingQueue.reset(); + + assertThat(reorderingQueue.poll(/* cutoffTimestampMs= */ 0)).isNull(); + } + + @Test + public void reorder_withPacketArriveOutOfOrderButInTime_returnsPacketsInCorrectOrder() { + // The packets arrive in the order of 1, 4, 5, 2, 3, 6. The mis-positioned packets (2, 3) + // arrive in time, so the polling order is: 1, null, 2, 3, 4, 5, 6. + List polledPackets = new ArrayList<>(); + RtpPacket packet1 = makePacket(/* sequenceNumber= */ 1); + RtpPacket packet2 = makePacket(/* sequenceNumber= */ 2); + RtpPacket packet3 = makePacket(/* sequenceNumber= */ 3); + RtpPacket packet4 = makePacket(/* sequenceNumber= */ 4); + RtpPacket packet5 = makePacket(/* sequenceNumber= */ 5); + RtpPacket packet6 = makePacket(/* sequenceNumber= */ 6); + + // Offering 1, queue after offering: [1]. + reorderingQueue.offer(packet1, /* receivedTimestampMs= */ 1); + // Offering 4, queue after offering: [1, 4]. + reorderingQueue.offer(packet4, /* receivedTimestampMs= */ 2); + // polling 1, queue after polling: [4]. + polledPackets.add(reorderingQueue.poll(/* cutoffTimestampMs= */ 0)); + // Offering 5, queue after offering: [4, 5]. + reorderingQueue.offer(packet5, /* receivedTimestampMs= */ 3); + // Should not poll: still waiting for packet 2, since packet 4 is received at 2, after cutoff. + polledPackets.add(reorderingQueue.poll(/* cutoffTimestampMs= */ 1)); + // Offering 2, queue after offering: [2, 4, 5]. + reorderingQueue.offer(packet2, /* receivedTimestampMs= */ 4); + // polling 2, queue after polling: [4, 5]. + polledPackets.add(reorderingQueue.poll(/* cutoffTimestampMs= */ 2)); + // Offering 3, queue after offering: [3, 4, 5]. + reorderingQueue.offer(packet3, /* receivedTimestampMs= */ 5); + // polling 3, queue after polling: [4, 5]. + polledPackets.add(reorderingQueue.poll(/* cutoffTimestampMs= */ 3)); + // Offering 6, queue after offering: [4, 5, 6]. + reorderingQueue.offer(packet6, /* receivedTimestampMs= */ 6); + // polling 4, queue after polling: [5, 6]. + polledPackets.add(reorderingQueue.poll(/* cutoffTimestampMs= */ 4)); + // polling 5, queue after polling: [6]. + polledPackets.add(reorderingQueue.poll(/* cutoffTimestampMs= */ 5)); + // polling 6, queue after polling: []. + polledPackets.add(reorderingQueue.poll(/* cutoffTimestampMs= */ 6)); + + assertThat(polledPackets) + .containsExactly(packet1, null, packet2, packet3, packet4, packet5, packet6) + .inOrder(); + } + + @Test + public void + reorder_withPacketArriveOutOfOrderMissedDeadline_returnsPacketsWithSequenceNumberJump() { + // Packets arrive in order 1, 3, 4, 2. Packet 2 arrives after packet 3 is dequeued, so packet 2 + // is discarded. + List polledPackets = new ArrayList<>(); + RtpPacket packet1 = makePacket(/* sequenceNumber= */ 1); + RtpPacket packet2 = makePacket(/* sequenceNumber= */ 2); + RtpPacket packet3 = makePacket(/* sequenceNumber= */ 3); + RtpPacket packet4 = makePacket(/* sequenceNumber= */ 4); + + // Offering 1, queue after offering: [1]. + reorderingQueue.offer(packet1, /* receivedTimestampMs= */ 1); + // Offering 3, queue after offering: [1, 3]. + reorderingQueue.offer(packet3, /* receivedTimestampMs= */ 2); + // polling 1, queue after polling: [3]. + polledPackets.add(reorderingQueue.poll(/* cutoffTimestampMs= */ 1)); + // Queue after offering: [3, 4]. + reorderingQueue.offer(packet4, /* receivedTimestampMs= */ 3); + // Should poll packet 3 (receivedTimestampMs = 2), because 2 hasn't come after the wait. + // polling 3, queue after polling: [4]. + polledPackets.add(reorderingQueue.poll(/* cutoffTimestampMs= */ 2)); + // Offering 2, queue after offering: [4]. Should not add packet 2: packet 3 is already dequeued. + reorderingQueue.offer(packet2, /* receivedTimestampMs= */ 4); + // polling 4, queue after polling: []. + polledPackets.add(reorderingQueue.poll(/* cutoffTimestampMs= */ 3)); + + assertThat(polledPackets).containsExactly(packet1, packet3, packet4).inOrder(); + } + + @Test + public void reorder_withLargerThanAllowedJumpForwardInSequenceNumber_resetsQueue() { + RtpPacket packet1 = makePacket(/* sequenceNumber= */ 1); + RtpPacket packetWithSequenceNumberJump = + makePacket(/* sequenceNumber= */ 10 + RtpPacketReorderingQueue.MAX_SEQUENCE_LEAP_ALLOWED); + + // Offering 1, queue after offering: [1]. + reorderingQueue.offer(packet1, /* receivedTimestampMs= */ 1); + + // Queueing a packet with a sequence number that creates a shift larger than the allowed maximum + // will force reset the queue. + reorderingQueue.offer(packetWithSequenceNumberJump, /* receivedTimestampMs= */ 2); + assertThat(reorderingQueue.poll(/* cutoffTimestampMs= */ 0)) + .isEqualTo(packetWithSequenceNumberJump); + } + + @Test + public void reorder_withLargerThanAllowedJumpInSequenceNumberAndWrapAround_resetsQueue() { + RtpPacket packet1 = makePacket(/* sequenceNumber= */ 1); + RtpPacket packetWithSequenceNumberJump = + makePacket( + /* sequenceNumber= */ RtpPacket.MAX_SEQUENCE_NUMBER + - RtpPacketReorderingQueue.MAX_SEQUENCE_LEAP_ALLOWED + - 10); + + // Offering 1, queue after offering: [1]. + reorderingQueue.offer(packet1, /* receivedTimestampMs= */ 1); + // Queueing a packet with a sequence number that creates a shift larger than the allowed maximum + // will force reset the queue. + reorderingQueue.offer(packetWithSequenceNumberJump, /* receivedTimestampMs= */ 2); + + assertThat(reorderingQueue.poll(/* cutoffTimestampMs= */ 0)) + .isEqualTo(packetWithSequenceNumberJump); + } + + @Test + public void reorder_receivingOutOfOrderPacketWithWrapAround_returnsPacketsInCorrectOrder() { + RtpPacket packet2 = makePacket(/* sequenceNumber= */ 2); + RtpPacket packet3 = makePacket(/* sequenceNumber= */ 3); + RtpPacket packet65000 = makePacket(/* sequenceNumber= */ 65000); + RtpPacket packet65001 = makePacket(/* sequenceNumber= */ 65001); + RtpPacket packet65002 = makePacket(/* sequenceNumber= */ 65002); + RtpPacket packet65003 = makePacket(/* sequenceNumber= */ 65003); + RtpPacket packet65004 = makePacket(/* sequenceNumber= */ 65004); + + reorderingQueue.offer(packet65000, /* receivedTimestampMs= */ 1); + reorderingQueue.offer(packet65001, /* receivedTimestampMs= */ 2); + reorderingQueue.offer(packet65002, /* receivedTimestampMs= */ 3); + reorderingQueue.offer(packet65003, /* receivedTimestampMs= */ 4); + assertThat(reorderingQueue.poll(/* cutoffTimestampMs= */ 0)).isEqualTo(packet65000); + reorderingQueue.offer(packet2, /* receivedTimestampMs= */ 5); + reorderingQueue.offer(packet3, /* receivedTimestampMs= */ 6); + reorderingQueue.offer(packet65004, /* receivedTimestampMs= */ 7); + + assertThat(reorderingQueue.poll(/* cutoffTimestampMs= */ 0)).isEqualTo(packet65001); + assertThat(reorderingQueue.poll(/* cutoffTimestampMs= */ 0)).isEqualTo(packet65002); + assertThat(reorderingQueue.poll(/* cutoffTimestampMs= */ 0)).isEqualTo(packet65003); + assertThat(reorderingQueue.poll(/* cutoffTimestampMs= */ 0)).isEqualTo(packet65004); + assertThat(reorderingQueue.poll(/* cutoffTimestampMs= */ 6)).isEqualTo(packet2); + assertThat(reorderingQueue.poll(/* cutoffTimestampMs= */ 0)).isEqualTo(packet3); + } + + private static RtpPacket makePacket(int sequenceNumber) { + return new RtpPacket.Builder().setSequenceNumber(sequenceNumber).build(); + } +} diff --git a/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpPacketTest.java b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpPacketTest.java new file mode 100644 index 0000000000..5246d7232b --- /dev/null +++ b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/rtp/RtpPacketTest.java @@ -0,0 +1,187 @@ +/* + * Copyright 2020 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 com.google.android.exoplayer2.source.rtsp.rtp; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.getBytesFromHexString; +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import java.util.Arrays; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link RtpPacket}. */ +@RunWith(AndroidJUnit4.class) +public final class RtpPacketTest { + /* + 10.. .... = Version: RFC 1889 Version (2) + ..0. .... = Padding: False + ...0 .... = Extension: False + .... 0000 = Contributing source identifiers count: 0 + 1... .... = Marker: True + Payload type: DynamicRTP-Type-96 (96) + Sequence number: 22159 + Timestamp: 55166400 + Synchronization Source identifier: 0xd76ef1a6 (3614372262) + Payload: 019fb174427f00006c10c4008962e33ceb5f1fde8ee2d0d9… + */ + private final byte[] rtpData = + getBytesFromHexString( + "80e0568f0349c5c0d76ef1a6019fb174427f00006c10c4008962e33ceb5f1fde8ee2d0d9b169651024c83b24c3a0f274ea327e2440ae0d3e2ed194beaa2c91edaa5d1e1df7ce30d1ca3726804d2db37765cf3d174338459623bc627c15c687045390a8d702f623a8dbe49e5c7896dbd7105daecb02ce30c0eee324c0c21ed820a0e67344c7a6e10859"); + private final byte[] rtpPayloadData = + Arrays.copyOfRange(rtpData, RtpPacket.MIN_HEADER_SIZE, rtpData.length); + + /* + 10.. .... = Version: RFC 1889 Version (2) + ..0. .... = Padding: False + ...0 .... = Extension: False + .... 0000 = Contributing source identifiers count: 0 + 1... .... = Marker: True + Payload type: DynamicRTP-Type-96 (96) + Sequence number: 29234 + Timestamp: 3688686074 + Synchronization Source identifier: 0xf5fe62a4 (4127089316) + Payload: 419a246c43bffea996000003000003000003000003000003… + */ + private final byte[] rtpDataWithLargeTimestamp = + getBytesFromHexString( + "80e07232dbdce1faf5fe62a4419a246c43bffea99600000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300000300002ce0"); + private final byte[] rtpWithLargeTimestampPayloadData = + Arrays.copyOfRange( + rtpDataWithLargeTimestamp, RtpPacket.MIN_HEADER_SIZE, rtpDataWithLargeTimestamp.length); + + @Test + public void parseRtpPacket() { + RtpPacket packet = checkNotNull(RtpPacket.parse(rtpData, rtpData.length)); + + assertThat(packet.version).isEqualTo(RtpPacket.RTP_VERSION); + assertThat(packet.padding).isFalse(); + assertThat(packet.extension).isFalse(); + assertThat(packet.csrcCount).isEqualTo(0); + assertThat(packet.csrc).hasLength(0); + assertThat(packet.marker).isTrue(); + assertThat(packet.payloadType).isEqualTo(96); + assertThat(packet.sequenceNumber).isEqualTo(22159); + assertThat(packet.timestamp).isEqualTo(55166400); + assertThat(packet.ssrc).isEqualTo(0xD76EF1A6); + assertThat(packet.payloadData).isEqualTo(rtpPayloadData); + } + + @Test + public void parseRtpPacketWithLargeTimestamp() { + RtpPacket packet = + checkNotNull(RtpPacket.parse(rtpDataWithLargeTimestamp, rtpDataWithLargeTimestamp.length)); + + assertThat(packet.version).isEqualTo(RtpPacket.RTP_VERSION); + assertThat(packet.padding).isFalse(); + assertThat(packet.extension).isFalse(); + assertThat(packet.csrcCount).isEqualTo(0); + assertThat(packet.csrc).hasLength(0); + assertThat(packet.marker).isTrue(); + assertThat(packet.payloadType).isEqualTo(96); + assertThat(packet.sequenceNumber).isEqualTo(29234); + assertThat(packet.timestamp).isEqualTo(3688686074L); + assertThat(packet.ssrc).isEqualTo(0xf5fe62a4); + assertThat(packet.payloadData).isEqualTo(rtpWithLargeTimestampPayloadData); + } + + @Test + public void writetoBuffer_withProperlySizedBuffer_writesPacket() { + int packetByteLength = rtpData.length; + RtpPacket packet = checkNotNull(RtpPacket.parse(rtpData, packetByteLength)); + + byte[] testBuffer = new byte[packetByteLength]; + int writtenBytes = packet.writeToBuffer(testBuffer, /* offset= */ 0, packetByteLength); + + assertThat(writtenBytes).isEqualTo(packetByteLength); + assertThat(testBuffer).isEqualTo(rtpData); + } + + @Test + public void writetoBuffer_withBufferTooSmall_doesNotWritePacket() { + int packetByteLength = rtpData.length; + RtpPacket packet = checkNotNull(RtpPacket.parse(rtpData, packetByteLength)); + + byte[] testBuffer = new byte[packetByteLength / 2]; + int writtenBytes = packet.writeToBuffer(testBuffer, /* offset= */ 0, packetByteLength); + + assertThat(writtenBytes).isEqualTo(C.LENGTH_UNSET); + } + + @Test + public void writetoBuffer_withProperlySizedBufferButSmallLengthParameter_doesNotWritePacket() { + int packetByteLength = rtpData.length; + RtpPacket packet = checkNotNull(RtpPacket.parse(rtpData, packetByteLength)); + + byte[] testBuffer = new byte[packetByteLength]; + int writtenBytes = packet.writeToBuffer(testBuffer, /* offset= */ 0, packetByteLength / 2); + + assertThat(writtenBytes).isEqualTo(C.LENGTH_UNSET); + } + + @Test + public void writetoBuffer_withProperlySizedBufferButNotEnoughSpaceLeft_doesNotWritePacket() { + int packetByteLength = rtpData.length; + RtpPacket packet = checkNotNull(RtpPacket.parse(rtpData, packetByteLength)); + + byte[] testBuffer = new byte[packetByteLength]; + int writtenBytes = + packet.writeToBuffer(testBuffer, /* offset= */ packetByteLength - 1, packetByteLength); + + assertThat(writtenBytes).isEqualTo(C.LENGTH_UNSET); + } + + @Test + public void buildRtpPacket() { + RtpPacket builtPacket = + new RtpPacket.Builder() + .setPadding(false) + .setMarker(true) + .setPayloadType((byte) 96) + .setSequenceNumber(22159) + .setTimestamp(55166400) + .setSsrc(0xD76EF1A6) + .setPayloadData(rtpPayloadData) + .build(); + + RtpPacket parsedPacket = checkNotNull(RtpPacket.parse(rtpData, rtpData.length)); + + // Test equals function. + assertThat(parsedPacket).isEqualTo(builtPacket); + } + + @Test + public void buildRtpPacketWithLargeTimestamp_matchesPacketData() { + RtpPacket builtPacket = + new RtpPacket.Builder() + .setPadding(false) + .setMarker(true) + .setPayloadType((byte) 96) + .setSequenceNumber(29234) + .setTimestamp(3688686074L) + .setSsrc(0xf5fe62a4) + .setPayloadData(rtpWithLargeTimestampPayloadData) + .build(); + + int packetSize = RtpPacket.MIN_HEADER_SIZE + builtPacket.payloadData.length; + byte[] builtPacketBytes = new byte[packetSize]; + builtPacket.writeToBuffer(builtPacketBytes, /* offset= */ 0, packetSize); + assertThat(builtPacketBytes).isEqualTo(rtpDataWithLargeTimestamp); + } +} diff --git a/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/sdp/SessionDescriptionTest.java b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/sdp/SessionDescriptionTest.java new file mode 100644 index 0000000000..b9af19034f --- /dev/null +++ b/library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/sdp/SessionDescriptionTest.java @@ -0,0 +1,173 @@ +/* + * 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 com.google.android.exoplayer2.source.rtsp.sdp; + +import static com.google.android.exoplayer2.source.rtsp.sdp.MediaDescription.MEDIA_TYPE_AUDIO; +import static com.google.android.exoplayer2.source.rtsp.sdp.MediaDescription.MEDIA_TYPE_VIDEO; +import static com.google.android.exoplayer2.source.rtsp.sdp.MediaDescription.RTP_AVP_PROFILE; +import static com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription.ATTR_CONTROL; +import static com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription.ATTR_FMTP; +import static com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription.ATTR_RANGE; +import static com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription.ATTR_RTPMAP; +import static com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription.ATTR_TOOL; +import static com.google.android.exoplayer2.source.rtsp.sdp.SessionDescription.ATTR_TYPE; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import android.net.Uri; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link SessionDescription}. */ +@RunWith(AndroidJUnit4.class) +public class SessionDescriptionTest { + + @Test + public void parse_sdpString_succeeds() throws Exception { + String testMediaSdpInfo = + "v=0\r\n" + + "o=MNobody 2890844526 2890842807 IN IP4 192.0.2.46\r\n" + + "s=SDP Seminar\r\n" + + "i=A Seminar on the session description protocol\r\n" + + "u=http://www.example.com/lectures/sdp.ps\r\n" + + "e=seminar@example.com (Seminar Management)\r\n" + + "c=IN IP4 0.0.0.0\r\n" + + "a=control:*\r\n" + + "t=2873397496 2873404696\r\n" + + "m=audio 3456 RTP/AVP 0\r\n" + + "a=control:audio\r\n" + + "a=rtpmap:0 PCMU/8000\r\n" + + "a=3GPP-Adaption-Support:1\r\n" + + "m=video 2232 RTP/AVP 31\r\n" + + "a=control:video\r\n" + + "a=rtpmap:31 H261/90000\r\n"; + + SessionDescription sessionDescription = SessionDescriptionParser.parse(testMediaSdpInfo); + + SessionDescription expectedSession = + new SessionDescription.Builder() + .setOrigin("MNobody 2890844526 2890842807 IN IP4 192.0.2.46") + .setSessionName("SDP Seminar") + .setSessionInfo("A Seminar on the session description protocol") + .setUri(Uri.parse("http://www.example.com/lectures/sdp.ps")) + .setEmailAddress("seminar@example.com (Seminar Management)") + .setConnection("IN IP4 0.0.0.0") + .setTiming("2873397496 2873404696") + .addAttribute(ATTR_CONTROL, "*") + .addMediaDescription( + new MediaDescription.Builder(MEDIA_TYPE_AUDIO, 3456, RTP_AVP_PROFILE, 0) + .addAttribute(ATTR_CONTROL, "audio") + .addAttribute(ATTR_RTPMAP, "0 PCMU/8000") + .addAttribute("3GPP-Adaption-Support", "1") + .build()) + .addMediaDescription( + new MediaDescription.Builder(MEDIA_TYPE_VIDEO, 2232, RTP_AVP_PROFILE, 31) + .addAttribute(ATTR_CONTROL, "video") + .addAttribute(ATTR_RTPMAP, "31 H261/90000") + .build()) + .build(); + + assertThat(sessionDescription).isEqualTo(expectedSession); + } + + @Test + public void parse_sdpString2_succeeds() throws Exception { + String testMediaSdpInfo = + "v=0\r\n" + + "o=- 1600785369059721 1 IN IP4 192.168.2.176\r\n" + + "s=video+audio, streamed by ExoPlayer\r\n" + + "i=test.mkv\r\n" + + "t=0 0\r\n" + + "a=tool:ExoPlayer\r\n" + + "a=type:broadcast\r\n" + + "a=control:*\r\n" + + "a=range:npt=0-30.102\r\n" + + "m=video 0 RTP/AVP 96\r\n" + + "c=IN IP4 0.0.0.0\r\n" + + "b=AS:500\r\n" + + "a=rtpmap:96 H264/90000\r\n" + + "a=fmtp:96" + + " packetization-mode=1;profile-level-id=64001F;sprop-parameter-sets=Z2QAH6zZQPARabIAAAMACAAAAwGcHjBjLA==,aOvjyyLAAA==\r\n" + + "a=control:track1\r\n" + + "m=audio 0 RTP/AVP 97\r\n" + + "c=IN IP4 0.0.0.0\r\n" + + "b=AS:96\r\n" + + "a=rtpmap:97 MPEG4-GENERIC/44100\r\n" + + "a=fmtp:97" + + " streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1208\r\n" + + "a=control:track2\r\n"; + + SessionDescription sessionDescription = SessionDescriptionParser.parse(testMediaSdpInfo); + + SessionDescription expectedSession = + new SessionDescription.Builder() + .setOrigin("- 1600785369059721 1 IN IP4 192.168.2.176") + .setSessionName("video+audio, streamed by ExoPlayer") + .setSessionInfo("test.mkv") + .setTiming("0 0") + .addAttribute(ATTR_TOOL, "ExoPlayer") + .addAttribute(ATTR_TYPE, "broadcast") + .addAttribute(ATTR_CONTROL, "*") + .addAttribute(ATTR_RANGE, "npt=0-30.102") + .addMediaDescription( + new MediaDescription.Builder(MEDIA_TYPE_VIDEO, 0, RTP_AVP_PROFILE, 96) + .setConnection("IN IP4 0.0.0.0") + .setBitrate(500_000) + .addAttribute(ATTR_RTPMAP, "96 H264/90000") + .addAttribute( + ATTR_FMTP, + "96 packetization-mode=1;profile-level-id=64001F;sprop-parameter-sets=Z2QAH6zZQPARabIAAAMACAAAAwGcHjBjLA==,aOvjyyLAAA==") + .addAttribute(ATTR_CONTROL, "track1") + .build()) + .addMediaDescription( + new MediaDescription.Builder(MEDIA_TYPE_AUDIO, 0, RTP_AVP_PROFILE, 97) + .setConnection("IN IP4 0.0.0.0") + .setBitrate(96_000) + .addAttribute(ATTR_RTPMAP, "97 MPEG4-GENERIC/44100") + .addAttribute( + ATTR_FMTP, + "97 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1208") + .addAttribute(ATTR_CONTROL, "track2") + .build()) + .build(); + + assertThat(sessionDescription).isEqualTo(expectedSession); + } + + @Test + public void buildMediaDescription_withInvalidRtpmapAttribute_throwsIllegalStateException() { + assertThrows( + IllegalStateException.class, + () -> + new MediaDescription.Builder( + MEDIA_TYPE_AUDIO, /* port= */ 0, RTP_AVP_PROFILE, /* payloadType= */ 97) + .addAttribute(ATTR_RTPMAP, "AF AC3/44100") + .build()); + } + + @Test + public void buildMediaDescription_withInvalidRtpmapAttribute2_throwsIllegalStateException() { + assertThrows( + IllegalStateException.class, + () -> + new MediaDescription.Builder( + MEDIA_TYPE_AUDIO, /* port= */ 0, RTP_AVP_PROFILE, /* payloadType= */ 97) + .addAttribute(ATTR_RTPMAP, "97 AC3/441A0") + .build()); + } +}