diff --git a/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspClient.java b/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspClient.java index c8ac907a8d..751731fce6 100644 --- a/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspClient.java +++ b/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspClient.java @@ -49,6 +49,7 @@ import androidx.media3.common.util.Log; import androidx.media3.common.util.Util; import androidx.media3.exoplayer.rtsp.RtspMediaPeriod.RtpLoadInfo; import androidx.media3.exoplayer.rtsp.RtspMediaSource.RtspPlaybackException; +import androidx.media3.exoplayer.rtsp.RtspMediaSource.RtspUdpUnsupportedTransportException; import androidx.media3.exoplayer.rtsp.RtspMessageChannel.InterleavedBinaryDataListener; import androidx.media3.exoplayer.rtsp.RtspMessageUtil.RtspAuthUserInfo; import androidx.media3.exoplayer.rtsp.RtspMessageUtil.RtspSessionHeader; @@ -577,8 +578,24 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; receivedAuthorizationRequest = true; return; } - // fall through: if unauthorized and no userInfo present, or previous authentication - // unsuccessful. + // if unauthorized and no userInfo present, or previous authentication + // unsuccessful, then dispatch RtspPlaybackException + dispatchRtspError( + new RtspPlaybackException( + RtspMessageUtil.toMethodString(requestMethod) + " " + response.status)); + return; + case 461: + String exceptionMessage = + RtspMessageUtil.toMethodString(requestMethod) + " " + response.status; + // If request was SETUP with UDP transport protocol, then throw + // RtspUdpUnsupportedTransportException. + String transportHeaderValue = + checkNotNull(matchingRequest.headers.get(RtspHeaders.TRANSPORT)); + dispatchRtspError( + requestMethod == METHOD_SETUP && !transportHeaderValue.contains("TCP") + ? new RtspUdpUnsupportedTransportException(exceptionMessage) + : new RtspPlaybackException(exceptionMessage)); + return; default: dispatchRtspError( new RtspPlaybackException( diff --git a/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaPeriod.java b/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaPeriod.java index 9dd40cba7c..1af0f2b415 100644 --- a/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaPeriod.java +++ b/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaPeriod.java @@ -518,7 +518,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; // using TCP. Retrying will setup new loadables, so will not retry with the current // loadables. retryWithRtpTcp(); - isUsingRtpTcp = true; } return; } @@ -644,7 +643,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @Override public void onPlaybackError(RtspPlaybackException error) { - playbackException = error; + if (error instanceof RtspMediaSource.RtspUdpUnsupportedTransportException && !isUsingRtpTcp) { + // Retry playback with TCP if we receive RtspUdpUnsupportedTransportException, and we are + // not already using TCP. Retrying will setup new loadables. + retryWithRtpTcp(); + } else { + playbackException = error; + } } @Override @@ -668,6 +673,9 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } private void retryWithRtpTcp() { + // Retry should only run once. + isUsingRtpTcp = true; + rtspClient.retryWithRtpTcp(); @Nullable diff --git a/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaSource.java b/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaSource.java index f2f6b52e61..0c69fe0df9 100644 --- a/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaSource.java +++ b/libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/RtspMediaSource.java @@ -192,7 +192,7 @@ public final class RtspMediaSource extends BaseMediaSource { } /** Thrown when an exception or error is encountered during loading an RTSP stream. */ - public static final class RtspPlaybackException extends IOException { + public static class RtspPlaybackException extends IOException { public RtspPlaybackException(String message) { super(message); } @@ -206,6 +206,13 @@ public final class RtspMediaSource extends BaseMediaSource { } } + /** Thrown when an RTSP Unsupported Transport error (461) is encountered during RTSP Setup. */ + public static final class RtspUdpUnsupportedTransportException extends RtspPlaybackException { + public RtspUdpUnsupportedTransportException(String message) { + super(message); + } + } + private final MediaItem mediaItem; private final RtpDataChannel.Factory rtpDataChannelFactory; private final String userAgent; diff --git a/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspClientTest.java b/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspClientTest.java index 104f6ae9c3..e699de2ea7 100644 --- a/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspClientTest.java +++ b/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspClientTest.java @@ -453,4 +453,77 @@ public final class RtspClientTest { RobolectricUtil.runMainLooperUntil(timelineRequestFailed::get); assertThat(rtspClient.getState()).isEqualTo(RtspClient.RTSP_STATE_UNINITIALIZED); } + + @Test + public void connectServerAndClient_describeResponseRequiresAuthentication_doesNotUpdateTimeline() + throws Exception { + class ResponseProvider implements RtspServer.ResponseProvider { + @Override + public RtspResponse getOptionsResponse() { + return new RtspResponse( + /* status= */ 200, + new RtspHeaders.Builder().add(RtspHeaders.PUBLIC, "OPTIONS, DESCRIBE").build()); + } + + @Override + public RtspResponse getDescribeResponse(Uri requestedUri, RtspHeaders headers) { + String authorizationHeader = headers.get(RtspHeaders.AUTHORIZATION); + if (authorizationHeader == null) { + return new RtspResponse( + /* status= */ 401, + new RtspHeaders.Builder() + .add(RtspHeaders.CSEQ, headers.get(RtspHeaders.CSEQ)) + .add( + RtspHeaders.WWW_AUTHENTICATE, + "Digest realm=\"RTSP server\"," + + " nonce=\"0cdfe9719e7373b7d5bb2913e2115f3f\"," + + " opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"") + .add(RtspHeaders.WWW_AUTHENTICATE, "BASIC realm=\"WallyWorld\"") + .build()); + } + if (!authorizationHeader.contains("Digest")) { + return new RtspResponse( + 401, + new RtspHeaders.Builder() + .add(RtspHeaders.CSEQ, headers.get(RtspHeaders.CSEQ)) + .build()); + } + + return RtspTestUtils.newDescribeResponseWithSdpMessage( + "v=0\r\n" + + "o=- 1606776316530225 1 IN IP4 127.0.0.1\r\n" + + "s=Exoplayer test\r\n" + + "t=0 0\r\n" + // The session is 50.46s long. + + "a=range:npt=0-50.46\r\n", + rtpPacketStreamDumps, + requestedUri); + } + } + rtspServer = new RtspServer(new ResponseProvider()); + + AtomicBoolean timelineRequestFailed = new AtomicBoolean(); + rtspClient = + new RtspClient( + new SessionInfoListener() { + @Override + public void onSessionTimelineUpdated( + RtspSessionTiming timing, ImmutableList tracks) {} + + @Override + public void onSessionTimelineRequestFailed( + String message, @Nullable Throwable cause) { + timelineRequestFailed.set(true); + } + }, + EMPTY_PLAYBACK_LISTENER, + /* userAgent= */ "ExoPlayer:RtspClientTest", + RtspTestUtils.getTestUri(rtspServer.startAndGetPortNumber()), + SocketFactory.getDefault(), + /* debugLoggingEnabled= */ false); + rtspClient.start(); + + RobolectricUtil.runMainLooperUntil(timelineRequestFailed::get); + assertThat(rtspClient.getState()).isEqualTo(RtspClient.RTSP_STATE_UNINITIALIZED); + } } diff --git a/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspPlaybackTest.java b/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspPlaybackTest.java index dc44ce154c..37650c297b 100644 --- a/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspPlaybackTest.java +++ b/libraries/exoplayer_rtsp/src/test/java/androidx/media3/exoplayer/rtsp/RtspPlaybackTest.java @@ -15,6 +15,7 @@ */ package androidx.media3.exoplayer.rtsp; +import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkStateNotNull; import static com.google.common.truth.Truth.assertThat; import static java.lang.Math.min; @@ -42,11 +43,13 @@ import androidx.media3.test.utils.robolectric.TestPlayerRunHelper; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.collect.ImmutableList; +import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicReference; import javax.net.SocketFactory; +import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -58,30 +61,20 @@ import org.robolectric.annotation.Config; @RunWith(AndroidJUnit4.class) public final class RtspPlaybackTest { + private static final long DEFAULT_TIMEOUT_MS = 8000; private static final String SESSION_DESCRIPTION = "v=0\r\n" + "o=- 1606776316530225 1 IN IP4 127.0.0.1\r\n" + "s=Exoplayer test\r\n" + "t=0 0\r\n"; - private final Context applicationContext; - private final CapturingRenderersFactory capturingRenderersFactory; - private final Clock clock; - private final FakeUdpDataSourceRtpDataChannel fakeRtpDataChannel; - private final RtpDataChannel.Factory rtpDataChannelFactory; - + private Context applicationContext; + private CapturingRenderersFactory capturingRenderersFactory; + private Clock clock; private RtpPacketStreamDump aacRtpPacketStreamDump; // ExoPlayer does not support extracting MP4A-LATM RTP payload at the moment. private RtpPacketStreamDump mpeg2tsRtpPacketStreamDump; - - /** Creates a new instance. */ - public RtspPlaybackTest() { - applicationContext = ApplicationProvider.getApplicationContext(); - capturingRenderersFactory = new CapturingRenderersFactory(applicationContext); - clock = new FakeClock(/* isAutoAdvancing= */ true); - fakeRtpDataChannel = new FakeUdpDataSourceRtpDataChannel(); - rtpDataChannelFactory = (trackId) -> fakeRtpDataChannel; - } + private RtspServer rtspServer; @Rule public ShadowMediaCodecConfig mediaCodecConfig = @@ -89,61 +82,162 @@ public final class RtspPlaybackTest { @Before public void setUp() throws Exception { + applicationContext = ApplicationProvider.getApplicationContext(); + capturingRenderersFactory = new CapturingRenderersFactory(applicationContext); + clock = new FakeClock(/* isAutoAdvancing= */ true); aacRtpPacketStreamDump = RtspTestUtils.readRtpPacketStreamDump("media/rtsp/aac-dump.json"); mpeg2tsRtpPacketStreamDump = RtspTestUtils.readRtpPacketStreamDump("media/rtsp/mpeg2ts-dump.json"); } + @After + public void tearDown() { + Util.closeQuietly(rtspServer); + } + @Test public void prepare_withSupportedTrack_playsTrackUntilEnded() throws Exception { + FakeUdpDataSourceRtpDataChannel fakeRtpDataChannel = new FakeUdpDataSourceRtpDataChannel(); + RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeRtpDataChannel; ResponseProvider responseProvider = new ResponseProvider( clock, ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump), fakeRtpDataChannel); + rtspServer = new RtspServer(responseProvider); + ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory); - try (RtspServer rtspServer = new RtspServer(responseProvider)) { - ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory); + PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory); + player.prepare(); + player.play(); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); - PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory); - player.prepare(); - player.play(); - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); - player.release(); - - // Only setup the supported track (aac). - assertThat(responseProvider.getDumpsForSetUpTracks()).containsExactly(aacRtpPacketStreamDump); - DumpFileAsserts.assertOutput( - applicationContext, playbackOutput, "playbackdumps/rtsp/aac.dump"); - } + // Only setup the supported track (aac). + assertThat(responseProvider.getDumpsForSetUpTracks()).containsExactly(aacRtpPacketStreamDump); + DumpFileAsserts.assertOutput(applicationContext, playbackOutput, "playbackdumps/rtsp/aac.dump"); } @Test public void prepare_noSupportedTrack_throwsPreparationError() throws Exception { - - try (RtspServer rtspServer = + FakeUdpDataSourceRtpDataChannel fakeRtpDataChannel = new FakeUdpDataSourceRtpDataChannel(); + RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeRtpDataChannel; + rtspServer = new RtspServer( new ResponseProvider( - clock, ImmutableList.of(mpeg2tsRtpPacketStreamDump), fakeRtpDataChannel))) { - ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory); + clock, ImmutableList.of(mpeg2tsRtpPacketStreamDump), fakeRtpDataChannel)); + ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory); - AtomicReference playbackError = new AtomicReference<>(); - player.prepare(); - player.addListener( - new Listener() { - @Override - public void onPlayerError(PlaybackException error) { - playbackError.set(error); - } - }); - RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null); - player.release(); + AtomicReference playbackError = new AtomicReference<>(); + player.prepare(); + player.addListener( + new Listener() { + @Override + public void onPlayerError(PlaybackException error) { + playbackError.set(error); + } + }); + RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null); + player.release(); - assertThat(playbackError.get()) - .hasCauseThat() - .hasMessageThat() - .contains("No playable track."); - } + assertThat(playbackError.get()).hasCauseThat().hasMessageThat().contains("No playable track."); + } + + @Test + public void prepare_withUdpUnsupportedWithFallback_fallsbackToTcpAndPlaysUntilEnd() + throws Exception { + FakeTcpDataSourceRtpDataChannel fakeTcpRtpDataChannel = new FakeTcpDataSourceRtpDataChannel(); + RtpDataChannel.Factory rtpTcpDataChannelFactory = (trackId) -> fakeTcpRtpDataChannel; + ResponseProviderSupportingOnlyTcp responseProviderSupportingOnlyTcp = + new ResponseProviderSupportingOnlyTcp( + clock, + ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump), + fakeTcpRtpDataChannel); + ForwardingRtpDataChannelFactory forwardingRtpDataChannelFactory = + new ForwardingRtpDataChannelFactory( + new UdpDataSourceRtpDataChannelFactory(DEFAULT_TIMEOUT_MS), rtpTcpDataChannelFactory); + rtspServer = new RtspServer(responseProviderSupportingOnlyTcp); + ExoPlayer player = + createExoPlayer(rtspServer.startAndGetPortNumber(), forwardingRtpDataChannelFactory); + + PlaybackOutput playbackOutput = PlaybackOutput.register(player, capturingRenderersFactory); + player.prepare(); + player.play(); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + player.release(); + + // Only setup the supported track (aac). + assertThat(responseProviderSupportingOnlyTcp.getDumpsForSetUpTracks()) + .containsExactly(aacRtpPacketStreamDump); + DumpFileAsserts.assertOutput(applicationContext, playbackOutput, "playbackdumps/rtsp/aac.dump"); + } + + @Test + public void prepare_withUdpUnsupportedWithoutFallback_throwsRtspPlaybackException() + throws Exception { + FakeUdpDataSourceRtpDataChannel fakeUdpRtpDataChannel = new FakeUdpDataSourceRtpDataChannel(); + RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeUdpRtpDataChannel; + ResponseProviderSupportingOnlyTcp responseProvider = + new ResponseProviderSupportingOnlyTcp( + clock, + ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump), + fakeUdpRtpDataChannel); + rtspServer = new RtspServer(responseProvider); + ExoPlayer player = createExoPlayer(rtspServer.startAndGetPortNumber(), rtpDataChannelFactory); + + AtomicReference playbackError = new AtomicReference<>(); + player.prepare(); + player.addListener( + new Listener() { + @Override + public void onPlayerError(PlaybackException error) { + playbackError.set(error); + } + }); + RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null); + player.release(); + + assertThat(playbackError.get()) + .hasCauseThat() + .isInstanceOf(RtspMediaSource.RtspPlaybackException.class); + assertThat(playbackError.get()) + .hasCauseThat() + .hasMessageThat() + .contains("No fallback data channel factory for TCP retry"); + } + + @Test + public void prepare_withUdpUnsupportedWithUdpFallback_throwsRtspUdpUnsupportedTransportException() + throws Exception { + FakeUdpDataSourceRtpDataChannel fakeUdpRtpDataChannel = new FakeUdpDataSourceRtpDataChannel(); + RtpDataChannel.Factory rtpDataChannelFactory = (trackId) -> fakeUdpRtpDataChannel; + ResponseProviderSupportingOnlyTcp responseProviderSupportingOnlyTcp = + new ResponseProviderSupportingOnlyTcp( + clock, + ImmutableList.of(aacRtpPacketStreamDump, mpeg2tsRtpPacketStreamDump), + fakeUdpRtpDataChannel); + ForwardingRtpDataChannelFactory forwardingRtpDataChannelFactory = + new ForwardingRtpDataChannelFactory(rtpDataChannelFactory, rtpDataChannelFactory); + rtspServer = new RtspServer(responseProviderSupportingOnlyTcp); + ExoPlayer player = + createExoPlayer(rtspServer.startAndGetPortNumber(), forwardingRtpDataChannelFactory); + + AtomicReference playbackError = new AtomicReference<>(); + player.prepare(); + player.addListener( + new Listener() { + @Override + public void onPlayerError(PlaybackException error) { + playbackError.set(error); + } + }); + RobolectricUtil.runMainLooperUntil(() -> playbackError.get() != null); + player.release(); + + assertThat(playbackError.get()) + .hasCauseThat() + .isInstanceOf(RtspMediaSource.RtspUdpUnsupportedTransportException.class); + assertThat(playbackError.get()).hasCauseThat().hasMessageThat().isEqualTo("SETUP 461"); } private ExoPlayer createExoPlayer( @@ -163,16 +257,16 @@ public final class RtspPlaybackTest { return player; } - private static final class ResponseProvider implements RtspServer.ResponseProvider { + private static class ResponseProvider implements RtspServer.ResponseProvider { - private static final String SESSION_ID = "00000000"; + protected static final String SESSION_ID = "00000000"; - private final Clock clock; - private final ArrayList dumpsForSetUpTracks; - private final ImmutableList rtpPacketStreamDumps; + protected final Clock clock; + protected final ArrayList dumpsForSetUpTracks; + protected final ImmutableList rtpPacketStreamDumps; private final RtspMessageChannel.InterleavedBinaryDataListener binaryDataListener; - private RtpPacketTransmitter packetTransmitter; + protected RtpPacketTransmitter packetTransmitter; /** * Creates a new instance. @@ -240,22 +334,54 @@ public final class RtspPlaybackTest { } } - private static final class FakeUdpDataSourceRtpDataChannel extends BaseDataSource - implements RtpDataChannel, RtspMessageChannel.InterleavedBinaryDataListener { + private static final class ResponseProviderSupportingOnlyTcp extends ResponseProvider { - private static final int LOCAL_PORT = 40000; + /** + * Creates a new instance. + * + * @param clock The {@link Clock} used in the test. + * @param rtpPacketStreamDumps A list of {@link RtpPacketStreamDump}. + * @param binaryDataListener A {@link RtspMessageChannel.InterleavedBinaryDataListener} to send + * RTP data. + */ + public ResponseProviderSupportingOnlyTcp( + Clock clock, + List rtpPacketStreamDumps, + RtspMessageChannel.InterleavedBinaryDataListener binaryDataListener) { + super(clock, rtpPacketStreamDumps, binaryDataListener); + } + + @Override + public RtspResponse getSetupResponse(Uri requestedUri, RtspHeaders headers) { + String transportHeaderValue = checkNotNull(headers.get(RtspHeaders.TRANSPORT)); + if (!transportHeaderValue.contains("TCP")) { + return new RtspResponse( + /* status= */ 461, headers.buildUpon().add(RtspHeaders.SESSION, SESSION_ID).build()); + } + for (RtpPacketStreamDump rtpPacketStreamDump : rtpPacketStreamDumps) { + if (requestedUri.toString().contains(rtpPacketStreamDump.trackName)) { + dumpsForSetUpTracks.add(rtpPacketStreamDump); + packetTransmitter = new RtpPacketTransmitter(rtpPacketStreamDump, clock); + } + } + return new RtspResponse( + /* status= */ 200, headers.buildUpon().add(RtspHeaders.SESSION, SESSION_ID).build()); + } + } + + private abstract static class FakeBaseDataSourceRtpDataChannel extends BaseDataSource + implements RtpDataChannel, RtspMessageChannel.InterleavedBinaryDataListener { + protected static final int LOCAL_PORT = 40000; private final ConcurrentLinkedQueue packetQueue; - public FakeUdpDataSourceRtpDataChannel() { + public FakeBaseDataSourceRtpDataChannel() { super(/* isNetwork= */ false); packetQueue = new ConcurrentLinkedQueue<>(); } @Override - public String getTransport() { - return Util.formatInvariant("RTP/AVP;unicast;client_port=%d-%d", LOCAL_PORT, LOCAL_PORT + 1); - } + public abstract String getTransport(); @Override public int getLocalPort() { @@ -307,4 +433,49 @@ public final class RtspPlaybackTest { return byteToRead; } } + + private static final class FakeUdpDataSourceRtpDataChannel + extends FakeBaseDataSourceRtpDataChannel { + @Override + public String getTransport() { + return Util.formatInvariant("RTP/AVP;unicast;client_port=%d-%d", LOCAL_PORT, LOCAL_PORT + 1); + } + + @Override + public RtspMessageChannel.InterleavedBinaryDataListener getInterleavedBinaryDataListener() { + return null; + } + } + + private static final class FakeTcpDataSourceRtpDataChannel + extends FakeBaseDataSourceRtpDataChannel { + @Override + public String getTransport() { + return Util.formatInvariant( + "RTP/AVP/TCP;unicast;interleaved=%d-%d", LOCAL_PORT + 2, LOCAL_PORT + 3); + } + } + + private static class ForwardingRtpDataChannelFactory implements RtpDataChannel.Factory { + + private final RtpDataChannel.Factory rtpChannelFactory; + private final RtpDataChannel.Factory rtpFallbackChannelFactory; + + public ForwardingRtpDataChannelFactory( + RtpDataChannel.Factory rtpChannelFactory, + RtpDataChannel.Factory rtpFallbackChannelFactory) { + this.rtpChannelFactory = rtpChannelFactory; + this.rtpFallbackChannelFactory = rtpFallbackChannelFactory; + } + + @Override + public RtpDataChannel createAndOpenDataChannel(int trackId) throws IOException { + return rtpChannelFactory.createAndOpenDataChannel(trackId); + } + + @Override + public RtpDataChannel.Factory createFallbackDataChannelFactory() { + return rtpFallbackChannelFactory; + } + } }