diff --git a/android/app/lint-baseline.xml b/android/app/lint-baseline.xml
index e067a404..aea14148 100644
--- a/android/app/lint-baseline.xml
+++ b/android/app/lint-baseline.xml
@@ -8,26 +8,10 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
-
-
-
-
-
@@ -167,7 +151,7 @@
errorLine2=" ~~~~~~~~~~~~~~">
diff --git a/android/app/src/main/java/com/openipc/decoder/AudioManager.java b/android/app/src/main/java/com/openipc/decoder/AudioManager.java
index e2a998b1..a725e184 100644
--- a/android/app/src/main/java/com/openipc/decoder/AudioManager.java
+++ b/android/app/src/main/java/com/openipc/decoder/AudioManager.java
@@ -94,9 +94,9 @@ void configure(AudioConfig audio) {
/** Called from the network thread to deliver an audio RTP packet. */
void onAudioFrame(Frame frame) {
byte[] data = frame.data();
- int start = RtspClient.rtpHeaderLength(data, frame.length());
+ int start = RtspSession.rtpHeaderLength(data, frame.length());
if (start < 0) return;
- int end = RtspClient.rtpPayloadEnd(data, frame.length());
+ int end = RtspSession.rtpPayloadEnd(data, frame.length());
if (end <= start) return;
AudioConfig c = config;
diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java
index ff7ae467..6b81f068 100755
--- a/android/app/src/main/java/com/openipc/decoder/Decoder.java
+++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java
@@ -371,7 +371,6 @@ private void saveSettings() {
listenerGen++;
if (rtspClient != null) {
- rtspClient.invalidateGen();
rtspClient.stop();
}
audioManager.closeAudio();
@@ -460,7 +459,6 @@ private void updateStatus(String status) {
private void startQuad() {
if (rtspClient != null) {
- rtspClient.invalidateGen();
rtspClient.stop();
}
closeDecoder();
@@ -725,7 +723,6 @@ protected void onPause() {
listenerGen++;
if (rtspClient != null) {
- rtspClient.invalidateGen();
rtspClient.stop();
}
if (executor != null) {
diff --git a/android/app/src/main/java/com/openipc/decoder/QuadCell.java b/android/app/src/main/java/com/openipc/decoder/QuadCell.java
index 0e1b0ef8..4a34b549 100644
--- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java
+++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java
@@ -8,27 +8,17 @@
package com.openipc.decoder;
import android.media.MediaCodec;
-import android.os.SystemClock;
-import android.util.Base64;
import android.util.Log;
import android.view.Surface;
import android.view.TextureView;
-import java.io.BufferedInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.net.InetSocketAddress;
-import java.net.Socket;
-import android.net.Uri;
-import java.nio.charset.StandardCharsets;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
-class QuadCell {
+class QuadCell implements RtspSession.Callback {
private static final String TAG = "OpenIPCDecoder";
private final int index;
@@ -37,11 +27,11 @@ class QuadCell {
private final String tag;
private final FramePool cellFramePool;
private final String userAgent;
+ private final RtspSession session;
private volatile boolean running;
- private volatile boolean activeStream;
- private volatile long lastFrame;
private volatile boolean codecH265;
+ private volatile int videoPt = -1;
private volatile boolean started;
private volatile boolean threadsStarted;
@@ -51,16 +41,16 @@ class QuadCell {
private volatile boolean decoderFailed;
private final BlockingQueue nalQueue = new ArrayBlockingQueue<>(30);
- private NalAssembler nalAssembler;
+ private final NalAssembler nalAssembler;
private int lastUnknownPayload = -1;
- private volatile Socket tcpSocket;
private ExecutorService executor;
+
interface Listener {
void onRemoveOverlay(int index);
}
- private Listener listener;
+ private final Listener listener;
QuadCell(int index, String host, TextureView view, FramePool pool, String userAgent, Listener listener) {
this.index = index;
@@ -70,6 +60,7 @@ interface Listener {
this.cellFramePool = pool;
this.userAgent = userAgent;
this.listener = listener;
+ this.session = new RtspSession(tag, this);
this.nalAssembler = new NalAssembler(512 * 1024, () -> {
nalQueue.clear();
synchronized (decoderLock) {
@@ -133,67 +124,8 @@ public void onSurfaceTextureUpdated(android.graphics.SurfaceTexture st) {}
private void startThreads() {
if (threadsStarted) return;
threadsStarted = true;
- if (executor != null) {
- executor.shutdownNow();
- try {
- if (!executor.awaitTermination(1, TimeUnit.SECONDS)) {
- executor.shutdownNow();
- }
- } catch (InterruptedException e) {
- executor.shutdownNow();
- Thread.currentThread().interrupt();
- }
- executor = null;
- }
- executor = Executors.newFixedThreadPool(3);
-
- executor.execute(() -> {
- Thread.currentThread().setName(tag + "-net");
- int retryDelay = 1000;
- int consecutiveFailures = 0;
- final int MAX_RETRY_DELAY = 30000;
- final int MAX_CONSECUTIVE_FAILURES = 10;
- while (running) {
- try {
- connect();
- retryDelay = 1000;
- consecutiveFailures = 0;
- SystemClock.sleep(1000);
- } catch (Exception e) {
- activeStream = false;
- Log.w(TAG, tag + ": " + e.getMessage());
- consecutiveFailures++;
- if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
- Log.e(TAG, tag + ": too many consecutive failures, giving up");
- running = false;
- break;
- }
- SystemClock.sleep(retryDelay);
- retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
- }
- }
- });
-
- executor.execute(() -> {
- Thread.currentThread().setName(tag + "-wd");
- final long WATCHDOG_MS = 8000;
- while (running) {
- if (activeStream && lastFrame > 0
- && SystemClock.elapsedRealtime() - lastFrame > WATCHDOG_MS) {
- activeStream = false;
- Socket tcp = tcpSocket;
- if (tcp != null) {
- try { tcp.close(); } catch (Exception e) {
- Log.e(TAG, tag + " Error closing socket in watchdog", e);
- }
- }
- }
- SystemClock.sleep(1000);
- }
- });
-
+ executor = Executors.newSingleThreadExecutor(r -> new Thread(r, tag + "-dec"));
executor.execute(() -> {
- Thread.currentThread().setName(tag + "-dec");
while (running) {
try {
Frame f = nalQueue.poll(5, TimeUnit.MILLISECONDS);
@@ -206,20 +138,18 @@ private void startThreads() {
}
}
});
+
+ // Video only: Quad mode has no audio (#12)
+ session.configure(host, userAgent, false);
+ session.start();
}
void stop() {
started = false;
threadsStarted = false;
running = false;
- activeStream = false;
+ session.stop();
nalAssembler.reset();
- Socket tcp = tcpSocket;
- if (tcp != null) {
- try { tcp.close(); } catch (Exception e) {
- Log.e(TAG, tag + " Error closing TCP socket", e);
- }
- }
if (executor != null) {
executor.shutdownNow();
try {
@@ -242,166 +172,48 @@ void stop() {
surface = null;
}
- private void connect() throws Exception {
- nalAssembler.reset();
- lastUnknownPayload = -1;
- if (host == null || host.isEmpty()) {
- SystemClock.sleep(5000);
- return;
- }
-
- Uri uri = Uri.parse(host);
- String h = uri.getHost();
- if (h == null || h.isEmpty()) throw new IOException("Invalid host");
-
- Socket s = null;
- try {
- s = new Socket();
- int port = uri.getPort();
- s.connect(new InetSocketAddress(h, port < 0 ? 554 : port), 5000);
- s.setTcpNoDelay(true);
- s.setSoTimeout(5000);
-
- InputStream input = s.getInputStream();
- OutputStream w = s.getOutputStream();
-
- String user = uri.getUserInfo();
- String auth = "";
- if (user != null) {
- auth = "Authorization: Basic " +
- Base64.encodeToString(user.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP) + "\r\n";
- }
- String path = uri.getEncodedPath();
- String query = uri.getEncodedQuery();
- String rtspUrl = uri.getScheme() + "://" + h
- + (port >= 0 ? ":" + port : "")
- + (path != null ? path : "")
- + (query != null ? "?" + query : "");
-
- // DESCRIBE
- int seq = 1;
- w.write(("DESCRIBE " + rtspUrl + " RTSP/1.0\r\n" +
- "CSeq: " + seq + "\r\n" + auth + userAgent +
- "Accept: application/sdp\r\n\r\n").getBytes(StandardCharsets.UTF_8));
- w.flush();
-
- String contentLenStr = RtspClient.readRtspResponse(input, "Content-Length:");
- int sdpLen = 0;
- if (contentLenStr != null) {
- try { sdpLen = Integer.parseInt(contentLenStr); } catch (NumberFormatException ignored) {}
- }
- StringBuilder sdpBuf = new StringBuilder();
- byte[] skipBuf = new byte[512];
- while (sdpLen > 0) {
- int n = input.read(skipBuf, 0, Math.min(sdpLen, skipBuf.length));
- if (n <= 0) break;
- if (sdpBuf.length() < 4096)
- sdpBuf.append(new String(skipBuf, 0, n, StandardCharsets.UTF_8));
- sdpLen -= n;
- }
-
- // Parse SDP for video track only — reuse RtspClient's parser
- boolean[] h265Out = new boolean[1];
- String[] trackUrls = RtspClient.parseSdp(sdpBuf.toString(), rtspUrl,
- h265Out, null);
- codecH265 = h265Out[0];
- String videoControl = trackUrls[0];
-
- // SETUP
- seq++;
- w.write(("SETUP " + videoControl + " RTSP/1.0\r\n" +
- "CSeq: " + seq + "\r\n" + auth + userAgent +
- "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n\r\n")
- .getBytes(StandardCharsets.UTF_8));
- w.flush();
-
- String session = RtspClient.readRtspResponse(input, "Session:");
- if (session == null) throw new IOException("No Session");
- session = session.replaceAll("[\r\n]", "");
-
- // PLAY
- seq++;
- w.write(("PLAY " + rtspUrl + " RTSP/1.0\r\n" +
- "CSeq: " + seq + "\r\n" + auth + userAgent +
- "Session: " + session + "\r\n\r\n").getBytes(StandardCharsets.UTF_8));
- w.flush();
+ // ====================== RtspSession.Callback ======================
- RtspClient.readRtspResponse(input, null);
+ @Override
+ public void onStatus(String status) {}
- s.setSoTimeout(0);
- lastFrame = SystemClock.elapsedRealtime();
- activeStream = true;
- initDecoder();
-
- tcpSocket = s;
- try {
- readTcp(input);
- } finally {
- tcpSocket = null;
- try {
- w.write(("TEARDOWN " + rtspUrl + " RTSP/1.0\r\n" +
- "CSeq: " + (seq + 1) + "\r\n" + auth + userAgent +
- "Session: " + session + "\r\n\r\n")
- .getBytes(StandardCharsets.UTF_8));
- w.flush();
- } catch (Exception e) {
- Log.e(TAG, tag + " Error sending TEARDOWN", e);
- }
- }
- } finally {
- if (s != null) {
- try { s.close(); } catch (Exception e) {
- Log.e(TAG, tag + " Error closing socket in connect()", e);
- }
- }
- }
+ @Override
+ public void onSdp(Sdp sdp) {
+ nalAssembler.reset();
+ lastUnknownPayload = -1;
+ videoPt = sdp.videoPt;
+ codecH265 = sdp.videoH265;
+ initDecoder();
}
- private void readTcp(InputStream rawInput) throws IOException {
- BufferedInputStream input = new BufferedInputStream(rawInput, 65536);
- byte[] pktBuf = new byte[65535];
- while (activeStream && running) {
- int total = RtspClient.readInterleavedPacket(input, pktBuf);
- if (total < 0) { activeStream = false; break; }
-
- int channel = pktBuf[1];
- int len = total - 4;
- if (channel == 0) {
- Frame f = cellFramePool.obtain(len);
- System.arraycopy(pktBuf, 4, f.data(), 0, len);
- f.setLength(len);
- handlePacket(f);
+ @Override
+ public boolean onPacket(int channel, byte[] buf, int offset, int length) {
+ if (channel != RtspSession.CHANNEL_VIDEO || length < 12) return false;
+ // CSRC and header extensions are not supported for video yet (#10)
+ if ((buf[offset] & 0x0F) != 0 || (buf[offset] & 0x10) != 0) return false;
+ int pt = buf[offset + 1] & 0x7F;
+ if (pt != videoPt) {
+ if (pt != lastUnknownPayload) {
+ lastUnknownPayload = pt;
+ Log.w(TAG, tag + " unexpected PT: " + pt + " (expected " + videoPt + ")");
}
+ return false;
}
- }
-
- private void handlePacket(Frame frame) {
- if (frame.length() < 12) { cellFramePool.recycle(frame); return; }
- byte[] data = frame.data();
- if ((data[0] & 0x0F) != 0 || (data[0] & 0x10) != 0) { cellFramePool.recycle(frame); return; }
- int pt = data[1] & 0x7F;
- if (pt == 97 || pt == 96) {
- lastFrame = SystemClock.elapsedRealtime();
- codecH265 = (pt == 97);
- Frame output = nalAssembler.assemble(frame, codecH265);
- cellFramePool.recycle(frame);
- if (output != null && !nalQueue.offer(output)) {
- Log.w(TAG, tag + " queue full, dropping frame");
- cellFramePool.recycle(output);
- }
- } else if (pt != lastUnknownPayload) {
- lastUnknownPayload = pt;
- Log.w(TAG, tag + " unknown PT: " + pt);
- cellFramePool.recycle(frame);
- } else {
- cellFramePool.recycle(frame);
+ Frame frame = cellFramePool.obtain(length);
+ System.arraycopy(buf, offset, frame.data(), 0, length);
+ frame.setLength(length);
+ Frame output = nalAssembler.assemble(frame, codecH265);
+ cellFramePool.recycle(frame);
+ if (output != null && !nalQueue.offer(output)) {
+ Log.w(TAG, tag + " queue full, dropping frame");
+ cellFramePool.recycle(output);
}
+ return true;
}
private void decode(Frame buffer) {
if (buffer.length() < 5) return;
- lastFrame = SystemClock.elapsedRealtime();
int flag = 0;
int frag = NalAssembler.fragment(buffer.data()[4], codecH265);
@@ -437,13 +249,13 @@ private void initDecoder() {
mgr.createDecoder(codecH265);
synchronized (decoderLock) {
- if (mediaCodecManager != null) {
+ // Another thread was faster, or stop() ran meanwhile (onSdp is on the network thread)
+ if (mediaCodecManager != null || !running) {
mgr.closeDecoder();
return;
}
mediaCodecManager = mgr;
}
- lastFrame = SystemClock.elapsedRealtime();
if (listener != null) listener.onRemoveOverlay(index);
}
}
diff --git a/android/app/src/main/java/com/openipc/decoder/RtspClient.java b/android/app/src/main/java/com/openipc/decoder/RtspClient.java
index 6ad019fc..2a7ed1e5 100644
--- a/android/app/src/main/java/com/openipc/decoder/RtspClient.java
+++ b/android/app/src/main/java/com/openipc/decoder/RtspClient.java
@@ -1,59 +1,30 @@
/*
* Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
*
- * RtspClient.java — RTSP handshake and RTP-over-TCP streaming client
+ * RtspClient.java — single-view stream: RTSP session, NAL assembly, audio and quality
*
*/
package com.openipc.decoder;
-import android.net.Uri;
import android.os.SystemClock;
-import android.util.Base64;
import android.util.Log;
-import java.io.BufferedInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.net.InetSocketAddress;
-import java.net.Socket;
-import java.nio.charset.StandardCharsets;
-import java.util.Locale;
-import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.TimeUnit;
-class RtspClient {
+class RtspClient implements RtspSession.Callback {
private static final String TAG = "OpenIPCDecoder";
- static final int RTP_PT_H265 = 97;
- static final int RTP_PT_H264 = 96;
+ private final FramePool framePool;
+ private final NalAssembler nalAssembler;
+ private final BlockingQueue nalQueue;
+ private final RtspSession session = new RtspSession("rtsp", this);
- private static final long WATCHDOG_MS = 8000;
-
- private volatile String host;
- private volatile String userAgent;
- private volatile int listenerGen;
- private volatile boolean listener;
- private volatile boolean activeStream;
- private volatile long lastFrame;
-
- private FramePool framePool;
- private NalAssembler nalAssembler;
- private BlockingQueue nalQueue;
- private volatile Socket mTcpSocket;
- private ExecutorService executor;
-
- // Decoder state
+ // Session parameters from the SDP (network thread)
+ private volatile int videoPt = -1;
private volatile boolean codecH265;
- private volatile AudioConfig audioConfig = new AudioConfig();
- private volatile String videoTrackUrl;
- private volatile String audioTrackUrl;
- // Quality tracking
+ // Quality tracking (network thread)
private long lastQualityUpdateTime;
private long lastRtpTimestamp = -1;
private long lastRtpArrivalNs = -1;
@@ -61,14 +32,6 @@ class RtspClient {
private int jitterSampleCount;
private int lastUnknownPayload = -1;
- // First-frame tracking for status transition
- private boolean firstFrameReceived;
-
- // Bitrate tracking
- private volatile long streamBytes;
- private volatile long bitrateMarkNs;
-
- // Audio callback — set by owner
private AudioManager audioManager;
interface Listener {
@@ -90,506 +53,114 @@ interface Listener {
void setAudioManager(AudioManager am) { this.audioManager = am; }
void configure(String host, String userAgent) {
- this.host = host;
- this.userAgent = userAgent;
+ session.configure(host, userAgent, true);
}
- boolean isCodecH265() { return codecH265; }
- boolean isActive() { return activeStream; }
- boolean isListening() { return listener; }
- long getLastFrame() { return lastFrame; }
- long getStreamBytes() { return streamBytes; }
- long getBitrateMarkNs() { return bitrateMarkNs; }
+ boolean isActive() { return session.isActive(); }
+ long getStreamBytes() { return session.getStreamBytes(); }
+ long getBitrateMarkNs() { return session.getBitrateMarkNs(); }
void start() {
- if (listener) return;
- listener = true;
- listenerGen++;
- executor = Executors.newFixedThreadPool(5);
-
- final int gen = listenerGen;
- executor.execute(() -> {
- Thread.currentThread().setName("rtsp-network");
- int retryDelay = 1000;
- int consecutiveFailures = 0;
- final int MAX_RETRY_DELAY = 30000;
- final int MAX_CONSECUTIVE_FAILURES = 10;
-
- while (gen == listenerGen) {
- try {
- if (!activeStream) {
- rtspConnect();
- retryDelay = 1000;
- consecutiveFailures = 0;
- streamBytes = 0;
- bitrateMarkNs = System.nanoTime();
- if (listenerCb != null) listenerCb.onStatusChanged("disconnected");
- } else {
- SystemClock.sleep(1000);
- }
- } catch (Exception e) {
- consecutiveFailures++;
- activeStream = false;
- if (listenerCb != null) listenerCb.onStatusChanged("disconnected");
-
- if (consecutiveFailures <= 3 || (consecutiveFailures % 5 == 0)) {
- Log.w(TAG, "RTSP connection failed (" + consecutiveFailures + "): " + e.getMessage());
- }
-
- int jitter = (int)(Math.random() * 500);
- int delay = retryDelay + jitter;
- if (consecutiveFailures > MAX_CONSECUTIVE_FAILURES) {
- delay = MAX_RETRY_DELAY;
- }
- SystemClock.sleep(delay);
- retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
-
- if (consecutiveFailures > 20) {
- Log.i(TAG, "Resetting connection state after many failures");
- closeSockets();
- if (listenerCb != null) listenerCb.onStatusChanged("disconnected");
- consecutiveFailures = 15;
- }
- }
- }
- });
-
- executor.execute(() -> {
- Thread.currentThread().setName("rtsp-watchdog");
- while (gen == listenerGen) {
- if (activeStream && lastFrame > 0
- && SystemClock.elapsedRealtime() - lastFrame > WATCHDOG_MS) {
- Log.w(TAG, "Stream is inactive");
- activeStream = false;
- Socket tcp = mTcpSocket;
- if (tcp != null) try { tcp.close(); } catch (Exception ignored) {}
- }
- SystemClock.sleep(1000);
- }
- });
+ nalAssembler.reset();
+ session.start();
}
+ /** Non-blocking: the session threads exit on their own. */
void stop() {
- listener = false;
- listenerGen++;
- activeStream = false;
- closeSockets();
- if (executor != null) {
- executor.shutdownNow();
- try {
- if (!executor.awaitTermination(2, TimeUnit.SECONDS)) {
- executor.shutdownNow();
- }
- } catch (InterruptedException e) {
- executor.shutdownNow();
- Thread.currentThread().interrupt();
- }
- executor = null;
- }
- if (nalAssembler != null) nalAssembler.reset();
+ session.stop();
+ nalAssembler.reset();
}
- void invalidateGen() { listenerGen++; }
+ // ====================== RtspSession.Callback ======================
- private void closeSockets() {
- Socket tcp = mTcpSocket;
- if (tcp != null) {
- try { tcp.close(); } catch (Exception e) {
- Log.e(TAG, "Error closing TCP socket", e);
- }
- }
+ @Override
+ public void onStatus(String status) {
+ Listener l = listenerCb;
+ if (l != null) l.onStatusChanged(status);
}
- private void rtspConnect() throws Exception {
+ @Override
+ public void onSdp(Sdp sdp) {
nalAssembler.reset();
lastUnknownPayload = -1;
- String currentHost = host;
- if (currentHost == null || currentHost.isEmpty()) {
- throw new IOException("Camera slot not configured");
- }
- Uri uri = Uri.parse(currentHost);
- String h = uri.getHost();
- if (h == null || h.isEmpty()) {
- throw new IOException("Invalid RTSP URL: host is missing or empty");
- }
- Socket s = null;
- try {
- s = new Socket();
- int port = uri.getPort();
- s.connect(new InetSocketAddress(h, port < 0 ? 554 : port), 5000);
- s.setTcpNoDelay(true);
- s.setSoTimeout(5000);
- InputStream input = s.getInputStream();
- OutputStream w = s.getOutputStream();
-
- Log.d(TAG, "Start rtsp connection");
-
- String user = uri.getUserInfo();
- String auth = "";
- if (user != null) {
- auth = "Authorization: Basic " +
- Base64.encodeToString(user.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP) + "\r\n";
- }
- String path = uri.getEncodedPath();
- String query = uri.getEncodedQuery();
- String rtspUrl = uri.getScheme() + "://" + h
- + (port >= 0 ? ":" + port : "")
- + (path != null ? path : "")
- + (query != null ? "?" + query : "");
-
- int seq = 1;
- String desc = "DESCRIBE " + rtspUrl + " RTSP/1.0\r\n" +
- "CSeq: " + seq + "\r\n" + auth + userAgent + "Accept: application/sdp\r\n\r\n";
- w.write(desc.getBytes(StandardCharsets.UTF_8));
- w.flush();
-
- StringBuilder sdp = new StringBuilder();
- String contentLen = readRtspResponse(input, "Content-Length:", sdp);
- if (contentLen != null) {
- try {
- int len = Integer.parseInt(contentLen);
- byte[] body = new byte[len];
- int off = 0;
- while (off < len) {
- int n = input.read(body, off, len - off);
- if (n <= 0) break;
- off += n;
- }
- sdp.append(new String(body, 0, off, StandardCharsets.UTF_8));
- } catch (NumberFormatException ignored) {}
- } else if (sdp.length() == 0) {
- int saved = input.available();
- if (saved > 0) {
- byte[] body = new byte[Math.min(saved, 4096)];
- int n = input.read(body);
- if (n > 0) sdp.append(new String(body, 0, n, StandardCharsets.UTF_8));
- }
- }
- parseSdpAndNotify(sdp.toString(), rtspUrl);
-
- seq++;
- String video = "SETUP " + videoTrackUrl + " RTSP/1.0\r\n" +
- "CSeq: " + seq + "\r\n" + auth + userAgent +
- "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n\r\n";
- w.write(video.getBytes(StandardCharsets.UTF_8));
- w.flush();
-
- String session = readRtspResponse(input, "Session:");
- if (session == null) {
- throw new IOException("RTSP server did not return a Session header");
- }
- session = session.replaceAll("[\r\n]", "");
-
- seq++;
- String audio = "SETUP " + audioTrackUrl + " RTSP/1.0\r\n" +
- "CSeq: " + seq + "\r\n" + auth + userAgent +
- "Transport: RTP/AVP/TCP;unicast;interleaved=2-3\r\n" +
- "Session: " + session + "\r\n\r\n";
- w.write(audio.getBytes(StandardCharsets.UTF_8));
- w.flush();
-
- readRtspResponse(input, null);
-
- seq++;
- String play = "PLAY " + rtspUrl + " RTSP/1.0\r\n" +
- "CSeq: " + seq + "\r\n" + auth + userAgent + "Session: " + session + "\r\n\r\n";
- w.write(play.getBytes(StandardCharsets.UTF_8));
- w.flush();
-
- readRtspResponse(input, null);
- firstFrameReceived = false;
- if (listenerCb != null) listenerCb.onStatusChanged("buffering");
-
- s.setSoTimeout(0);
- lastFrame = SystemClock.elapsedRealtime();
- activeStream = true;
-
- mTcpSocket = s;
- try {
- tcpStream(input);
- } finally {
- mTcpSocket = null;
- }
- try {
- String teardown = "TEARDOWN " + rtspUrl + " RTSP/1.0\r\n" +
- "CSeq: " + (seq + 1) + "\r\n" + auth + userAgent +
- "Session: " + session + "\r\n\r\n";
- w.write(teardown.getBytes(StandardCharsets.UTF_8));
- w.flush();
- Log.d(TAG, "RTSP TEARDOWN sent");
- } catch (Exception e) {
- Log.e(TAG, "Error sending TEARDOWN", e);
- }
- } finally {
- if (s != null) {
- try { s.close(); } catch (Exception e) {
- Log.e(TAG, "Error closing TCP socket", e);
- }
- }
- }
- }
-
- private void tcpStream(InputStream rawInput) throws IOException {
- BufferedInputStream input = new BufferedInputStream(rawInput, 65536);
- byte[] pktBuf = new byte[65535];
- streamBytes = 0;
- bitrateMarkNs = System.nanoTime();
- while (activeStream) {
- int total = readInterleavedPacket(input, pktBuf);
- if (total < 0) { activeStream = false; break; }
-
- streamBytes += total;
- int channel = pktBuf[1];
- int len = total - 4;
- Frame frame = framePool.obtain(len);
- System.arraycopy(pktBuf, 4, frame.data(), 0, len);
- frame.setLength(len);
-
- if (channel == 0) {
- processPacket(frame);
- } else if (channel == 2) {
- if (audioManager != null) {
- audioManager.onAudioFrame(frame);
- }
- framePool.recycle(frame);
- } else {
- framePool.recycle(frame);
- }
- }
- }
-
- /** Read one RTP-over-TCP interleaved packet. Shared between RtspClient and QuadCell. */
- static int readInterleavedPacket(BufferedInputStream input, byte[] pktBuf) throws IOException {
- while (true) {
- int b0 = input.read();
- if (b0 < 0) return -1; // clean EOF
- if (b0 != '$') continue;
- int b1 = input.read();
- if (b1 < 0) throw new IOException("Truncated RTP header (channel)");
- int b2 = input.read();
- if (b2 < 0) throw new IOException("Truncated RTP header (len hi)");
- int b3 = input.read();
- if (b3 < 0) throw new IOException("Truncated RTP header (len lo)");
- int len = (b2 << 8) | b3;
- if (len + 4 > pktBuf.length)
- throw new IOException("RTP packet too large: " + len);
- pktBuf[0] = '$';
- pktBuf[1] = (byte) b1;
- pktBuf[2] = (byte) b2;
- pktBuf[3] = (byte) b3;
- int offset = 4;
- int remaining = len;
- while (remaining > 0) {
- int n = input.read(pktBuf, offset, remaining);
- if (n < 0) throw new IOException("Truncated RTP body");
- remaining -= n;
- offset += n;
- }
- return len + 4;
- }
- }
-
- private void processPacket(Frame frame) {
- if (frame.length() < 12) { framePool.recycle(frame); return; }
- byte[] data = frame.data();
- if ((data[0] & 0x0F) != 0 || (data[0] & 0x10) != 0) { framePool.recycle(frame); return; }
-
- int payload = (data[1] & 0x7F);
- if (payload == audioConfig.pt) {
- if (audioManager != null) {
- audioManager.onAudioFrame(frame);
- }
- framePool.recycle(frame);
- return;
- } else if (payload == RTP_PT_H265 || payload == RTP_PT_H264) {
- if (!firstFrameReceived) {
- firstFrameReceived = true;
- if (listenerCb != null) listenerCb.onStatusChanged("connected");
- }
- lastFrame = SystemClock.elapsedRealtime();
- long now = SystemClock.elapsedRealtime();
- if (now - lastQualityUpdateTime > 1000) {
- lastQualityUpdateTime = now;
- int avgJitter = (jitterSampleCount > 0)
- ? (int)(jitterAccumulator / jitterSampleCount)
- : -1;
- if (listenerCb != null) listenerCb.onJitterSample(avgJitter);
- jitterAccumulator = 0;
- jitterSampleCount = 0;
- }
- if (lastRtpTimestamp >= 0 && lastRtpArrivalNs >= 0) {
- int rtpTs = ((data[4] & 0xFF) << 24)
- | ((data[5] & 0xFF) << 16)
- | ((data[6] & 0xFF) << 8)
- | (data[7] & 0xFF);
- long rtpDeltaMs = ((rtpTs & 0xFFFFFFFFL) - (lastRtpTimestamp & 0xFFFFFFFFL)) / 90L;
- long arrivalDeltaMs = (System.nanoTime() - lastRtpArrivalNs) / 1000000;
- long jitter = Math.abs(arrivalDeltaMs - rtpDeltaMs);
- if (jitter < 5000) {
- jitterAccumulator += jitter;
- jitterSampleCount++;
- }
- }
- lastRtpTimestamp = Integer.toUnsignedLong(
- ((data[4] & 0xFF) << 24) |
- ((data[5] & 0xFF) << 16) |
- ((data[6] & 0xFF) << 8) |
- (data[7] & 0xFF));
- lastRtpArrivalNs = System.nanoTime();
-
- codecH265 = payload == RTP_PT_H265;
- Frame output = nalAssembler.assemble(frame, codecH265);
- framePool.recycle(frame);
- if (output != null) {
- if (!nalQueue.offer(output)) {
- Log.w(TAG, "Video queue full, frame dropped");
- framePool.recycle(output);
- }
- }
- return;
- }
-
- if (payload != lastUnknownPayload) {
- lastUnknownPayload = payload;
- Log.w(TAG, "Unknown rtp type: " + payload);
+ lastRtpTimestamp = -1;
+ lastRtpArrivalNs = -1;
+ videoPt = sdp.videoPt;
+ codecH265 = sdp.videoH265;
+ Log.i(TAG, "Video pt " + sdp.videoPt + (sdp.videoH265 ? " H.265" : " H.264")
+ + ", audio " + sdp.audio.codec);
+ Listener l = listenerCb;
+ if (l != null) l.onCodecChanged(codecH265, sdp.audio);
+ }
+
+ @Override
+ public boolean onPacket(int channel, byte[] buf, int offset, int length) {
+ if (length < 12) return false;
+ Frame frame = framePool.obtain(length);
+ System.arraycopy(buf, offset, frame.data(), 0, length);
+ frame.setLength(length);
+
+ boolean usable = false;
+ if (channel == RtspSession.CHANNEL_VIDEO) {
+ usable = processVideo(frame);
+ } else if (channel == RtspSession.CHANNEL_AUDIO && audioManager != null) {
+ audioManager.onAudioFrame(frame);
}
framePool.recycle(frame);
+ return usable;
}
- private void parseSdpAndNotify(String sdp, String baseUrl) {
- boolean[] h265Out = new boolean[1];
- AudioConfig audio = new AudioConfig();
- String[] controls = parseSdp(sdp, baseUrl, h265Out, audio);
- codecH265 = h265Out[0];
- audioConfig = audio;
- videoTrackUrl = controls[0];
- audioTrackUrl = controls[1];
- if (listenerCb != null) {
- listenerCb.onCodecChanged(codecH265, audio);
- }
- }
-
- /**
- * Parse the SDP: returns { videoControlUrl, audioControlUrl }, stores the video codec
- * into {@code codecH265Out[0]} and the audio track parameters into {@code audioOut}
- * (both may be null).
- */
- static String[] parseSdp(String sdp, String baseUrl,
- boolean[] codecH265Out, AudioConfig audioOut) {
- String base = baseUrl;
- String[] controls = { null, null };
- int track = -1;
- boolean codecH265 = false;
- AudioConfig audio = audioOut != null ? audioOut : new AudioConfig();
+ /** Returns false for packets of the wrong payload type or with unsupported headers. */
+ private boolean processVideo(Frame frame) {
+ byte[] data = frame.data();
+ // CSRC and header extensions are not supported for video yet (#10)
+ if ((data[0] & 0x0F) != 0 || (data[0] & 0x10) != 0) return false;
- for (String line : sdp.split("[\r\n]+")) {
- if (line.startsWith("m=video")) {
- track = 0;
- String[] parts = line.split("\\s+");
- if (parts.length >= 4) {
- try {
- int pt = Integer.parseInt(parts[3]);
- codecH265 = (pt == 97);
- } catch (NumberFormatException ignored) {}
- }
- } else if (line.startsWith("m=audio")) {
- track = 1;
- String[] parts = line.split("\\s+");
- if (parts.length >= 4) {
- try {
- audio.pt = Integer.parseInt(parts[3]);
- audio.applyStaticPayloadType();
- } catch (NumberFormatException ignored) {}
- }
- } else if (line.startsWith("m=")) {
- track = 2; // other media, ignored
- } else if (line.startsWith("a=control:")) {
- String ctrl = line.substring("a=control:".length()).trim();
- if (track == -1) {
- base = ctrl.startsWith("rtsp://") ? ctrl : baseUrl + "/" + ctrl;
- } else if (track < 2) {
- controls[track] = ctrl.startsWith("rtsp://") ? ctrl : base + "/" + ctrl;
- }
- } else if (track == 1 && audio.pt >= 0
- && line.startsWith("a=rtpmap:" + audio.pt + " ")) {
- audio.applyRtpmap(line.substring(("a=rtpmap:" + audio.pt + " ").length()));
- } else if (track == 1 && audio.pt >= 0
- && line.startsWith("a=fmtp:" + audio.pt + " ")) {
- audio.applyFmtp(line.substring(("a=fmtp:" + audio.pt + " ").length()));
+ int payload = data[1] & 0x7F;
+ if (payload != videoPt) {
+ if (payload != lastUnknownPayload) {
+ lastUnknownPayload = payload;
+ Log.w(TAG, "Unexpected video rtp type: " + payload + " (expected " + videoPt + ")");
}
+ return false;
}
- audio.finish();
- if (controls[0] == null) controls[0] = base + "/trackID=0";
- if (controls[1] == null) controls[1] = base + "/trackID=1";
- if (codecH265Out != null && codecH265Out.length > 0) codecH265Out[0] = codecH265;
- return controls;
- }
+ updateJitter(data);
- /**
- * Length of the RTP header including CSRC list and header extension,
- * or -1 when the packet is malformed.
- */
- static int rtpHeaderLength(byte[] data, int length) {
- if (length < 12 || (data[0] & 0xC0) != 0x80) return -1;
- int len = 12 + 4 * (data[0] & 0x0F);
- if ((data[0] & 0x10) != 0) {
- if (len + 4 > length) return -1;
- int extWords = ((data[len + 2] & 0xFF) << 8) | (data[len + 3] & 0xFF);
- len += 4 + 4 * extWords;
+ Frame output = nalAssembler.assemble(frame, codecH265);
+ if (output != null && !nalQueue.offer(output)) {
+ Log.w(TAG, "Video queue full, frame dropped");
+ framePool.recycle(output);
}
- return len <= length ? len : -1;
- }
-
- /** Payload end offset: packet length minus RTP padding, or -1 when malformed. */
- static int rtpPayloadEnd(byte[] data, int length) {
- if ((data[0] & 0x20) == 0) return length;
- int pad = data[length - 1] & 0xFF;
- return pad <= length ? length - pad : -1;
- }
-
- static String readLine(InputStream in) throws IOException {
- StringBuilder sb = new StringBuilder(128);
- int c;
- while ((c = in.read()) != -1) {
- if (c == '\n') return sb.toString();
- if (c != '\r') {
- if (sb.length() >= 8192) throw new IOException("RTSP header line too long");
- sb.append((char) c);
- }
+ return true;
+ }
+
+ private void updateJitter(byte[] data) {
+ long now = SystemClock.elapsedRealtime();
+ if (now - lastQualityUpdateTime > 1000) {
+ lastQualityUpdateTime = now;
+ int avgJitter = jitterSampleCount > 0
+ ? (int) (jitterAccumulator / jitterSampleCount) : -1;
+ Listener l = listenerCb;
+ if (l != null) l.onJitterSample(avgJitter);
+ jitterAccumulator = 0;
+ jitterSampleCount = 0;
}
- return sb.length() > 0 ? sb.toString() : null;
- }
-
- /** Read RTSP response headers. Returns the value of {@code targetHeader} if found, or null. */
- static String readRtspResponse(InputStream in, String targetHeader) throws IOException {
- return readRtspResponse(in, targetHeader, null);
- }
-
- /** Read RTSP response headers and optionally accumulate body into {@code bodyOut}. */
- static String readRtspResponse(InputStream in, String targetHeader, StringBuilder bodyOut) throws IOException {
- String status = readLine(in);
- if (status == null) throw new IOException("Server closed connection during handshake");
- Log.i(TAG, status);
-
- String[] parts = status.split(" ", 3);
- if (parts.length < 2) throw new IOException("Malformed RTSP response: " + status);
- try {
- int code = Integer.parseInt(parts[1]);
- if (code < 200 || code >= 300) throw new IOException("RTSP error: " + status.trim());
- } catch (NumberFormatException e) {
- throw new IOException("Malformed RTSP status code: " + status);
- }
-
- String found = null;
- String line;
- while ((line = readLine(in)) != null && !line.isEmpty()) {
- Log.d(TAG, line);
- if (targetHeader != null && line.toLowerCase(Locale.ROOT)
- .startsWith(targetHeader.toLowerCase(Locale.ROOT))) {
- found = line.substring(targetHeader.length()).split(";")[0].trim();
+ long rtpTs = Integer.toUnsignedLong(((data[4] & 0xFF) << 24)
+ | ((data[5] & 0xFF) << 16)
+ | ((data[6] & 0xFF) << 8)
+ | (data[7] & 0xFF));
+ long arrivalNs = System.nanoTime();
+ if (lastRtpTimestamp >= 0 && lastRtpArrivalNs >= 0) {
+ long rtpDeltaMs = (rtpTs - lastRtpTimestamp) / 90L;
+ long arrivalDeltaMs = (arrivalNs - lastRtpArrivalNs) / 1_000_000;
+ long jitter = Math.abs(arrivalDeltaMs - rtpDeltaMs);
+ if (jitter < 5000) {
+ jitterAccumulator += jitter;
+ jitterSampleCount++;
}
}
- return found;
+ lastRtpTimestamp = rtpTs;
+ lastRtpArrivalNs = arrivalNs;
}
}
diff --git a/android/app/src/main/java/com/openipc/decoder/RtspSession.java b/android/app/src/main/java/com/openipc/decoder/RtspSession.java
new file mode 100644
index 00000000..592fa15c
--- /dev/null
+++ b/android/app/src/main/java/com/openipc/decoder/RtspSession.java
@@ -0,0 +1,554 @@
+/*
+ * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
+ *
+ * RtspSession.java — one RTSP-over-TCP session with automatic reconnect
+ *
+ */
+
+package com.openipc.decoder;
+
+import android.util.Base64;
+import android.util.Log;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Handshake (DESCRIBE, SETUP, PLAY), SDP parsing, the interleaved receive loop, a watchdog
+ * and reconnection with capped back-off. Used by the single view (RtspClient) and by every
+ * Quad cell. Packets are delivered on the network thread; channel 0 is video, channel 2 audio.
+ */
+class RtspSession {
+ private static final String TAG = "OpenIPCDecoder";
+
+ static final int CHANNEL_VIDEO = 0;
+ static final int CHANNEL_AUDIO = 2;
+
+ private static final int CONNECT_TIMEOUT_MS = 5000;
+ private static final int HANDSHAKE_TIMEOUT_MS = 5000;
+ // Not final so unit tests can shorten them
+ static volatile long watchdogMs = 8000;
+ static volatile int minRetryMs = 1000;
+ static volatile int maxRetryMs = 30000;
+
+ interface Callback {
+ /** "disconnected", "buffering" (after PLAY) or "connected" (first video packet). */
+ void onStatus(String status);
+
+ /** Session parameters, before PLAY. Audio is NONE when it was not set up. */
+ void onSdp(Sdp sdp);
+
+ /**
+ * One RTP packet: {@code length} bytes of {@code buf} starting at {@code offset}.
+ * Returns true if a video packet was usable; only those feed the watchdog.
+ */
+ boolean onPacket(int channel, byte[] buf, int offset, int length);
+ }
+
+ private final String tag;
+ private final Callback callback;
+
+ private volatile String url;
+ private volatile String userAgent = "";
+ private volatile boolean withAudio;
+
+ // Incremented by start()/stop(); threads and callbacks of an older generation do nothing
+ private volatile int generation;
+ private final Object lock = new Object();
+ private Socket socket;
+ private Thread networkThread;
+ private Thread watchdogThread;
+
+ private volatile boolean active;
+ private volatile long lastPacketMs;
+ private volatile long streamBytes;
+ private volatile long bitrateMarkNs;
+
+ RtspSession(String tag, Callback callback) {
+ this.tag = tag;
+ this.callback = callback;
+ }
+
+ /** @param userAgent full header line, e.g. "User-Agent: x\r\n" */
+ void configure(String url, String userAgent, boolean withAudio) {
+ this.url = url;
+ this.userAgent = userAgent != null ? userAgent : "";
+ this.withAudio = withAudio;
+ }
+
+ boolean isActive() { return active; }
+ long getStreamBytes() { return streamBytes; }
+ long getBitrateMarkNs() { return bitrateMarkNs; }
+
+ void start() {
+ synchronized (lock) {
+ if (networkThread != null) return;
+ final int gen = ++generation;
+ networkThread = new Thread(() -> networkLoop(gen), tag + "-net");
+ watchdogThread = new Thread(() -> watchdogLoop(gen), tag + "-wd");
+ networkThread.start();
+ watchdogThread.start();
+ }
+ }
+
+ /** Stop without waiting: threads exit on their own and deliver no more callbacks. */
+ void stop() {
+ synchronized (lock) {
+ generation++;
+ active = false;
+ closeSocketLocked();
+ if (networkThread != null) networkThread.interrupt();
+ if (watchdogThread != null) watchdogThread.interrupt();
+ networkThread = null;
+ watchdogThread = null;
+ }
+ }
+
+ private void closeSocketLocked() {
+ if (socket != null) {
+ try {
+ socket.close();
+ } catch (IOException ignored) {}
+ }
+ }
+
+ private void status(int gen, String status) {
+ if (gen == generation) callback.onStatus(status);
+ }
+
+ private void networkLoop(int gen) {
+ int retryMs = minRetryMs;
+ int failures = 0;
+ while (gen == generation) {
+ boolean played = false;
+ try {
+ played = connect(gen);
+ } catch (Exception e) {
+ failures++;
+ if (failures <= 3 || failures % 10 == 0) {
+ Log.w(TAG, tag + ": RTSP connection failed (" + failures + "): "
+ + e.getMessage());
+ }
+ }
+ if (gen != generation) break;
+ active = false;
+ status(gen, "disconnected");
+
+ // A session that reached PLAY resets the back-off; never give up
+ if (played) {
+ retryMs = minRetryMs;
+ failures = 0;
+ }
+ try {
+ Thread.sleep(retryDelay(retryMs));
+ } catch (InterruptedException e) {
+ break;
+ }
+ if (!played) retryMs = Math.min(retryMs * 2, maxRetryMs);
+ }
+ }
+
+ /** Back-off delay with jitter, never beyond maxRetryMs. */
+ static long retryDelay(int retryMs) {
+ return Math.min(maxRetryMs, retryMs + (long) (Math.random() * retryMs / 2));
+ }
+
+ private void watchdogLoop(int gen) {
+ while (gen == generation) {
+ if (active && System.nanoTime() / 1_000_000 - lastPacketMs > watchdogMs) {
+ Log.w(TAG, tag + ": stream is inactive, reconnecting");
+ active = false;
+ synchronized (lock) {
+ if (gen == generation) closeSocketLocked();
+ }
+ }
+ try {
+ Thread.sleep(Math.min(1000, watchdogMs / 4 + 1));
+ } catch (InterruptedException e) {
+ break;
+ }
+ }
+ }
+
+ /** One session from connect to disconnect. Returns true if PLAY succeeded. */
+ private boolean connect(int gen) throws IOException {
+ Url u = Url.parse(url);
+ Socket s = new Socket();
+ synchronized (lock) {
+ if (gen != generation) return false;
+ socket = s;
+ }
+ try {
+ s.connect(new InetSocketAddress(u.host, u.port), CONNECT_TIMEOUT_MS);
+ s.setTcpNoDelay(true);
+ s.setSoTimeout(HANDSHAKE_TIMEOUT_MS);
+ BufferedInputStream in = new BufferedInputStream(s.getInputStream(), 65536);
+ OutputStream out = s.getOutputStream();
+
+ String auth = u.userInfo != null
+ ? "Authorization: Basic " + Base64.encodeToString(
+ u.userInfo.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP) + "\r\n"
+ : "";
+ String common = auth + userAgent;
+ int cseq = 1;
+
+ Response r = request(in, out, "DESCRIBE", u.requestUrl, cseq++,
+ common + "Accept: application/sdp\r\n");
+ r.require("DESCRIBE");
+ if (r.header("content-length") == null && r.body.isEmpty()) {
+ // Some servers omit Content-Length: take what arrives until the line goes quiet
+ r.body = readUntilIdle(s, in);
+ }
+ String base = r.header("content-base");
+ Sdp sdp = Sdp.parse(r.body, base != null ? base : u.requestUrl);
+ if (sdp.videoPt < 0) {
+ // Nothing we could decode: fail so that the back-off applies, not a black view
+ throw new IOException("No H.264/H.265 video track in SDP");
+ }
+
+ r = request(in, out, "SETUP", sdp.videoUrl, cseq++,
+ common + "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n");
+ r.require("SETUP video");
+ String session = r.header("session");
+ if (session == null) throw new IOException("No Session header in SETUP reply");
+ session = session.split(";")[0].trim();
+
+ boolean audio = withAudio && sdp.audioUrl != null
+ && !AudioConfig.NONE.equals(sdp.audio.codec);
+ if (audio) {
+ r = request(in, out, "SETUP", sdp.audioUrl, cseq++, common +
+ "Transport: RTP/AVP/TCP;unicast;interleaved=2-3\r\n" +
+ "Session: " + session + "\r\n");
+ if (!r.ok()) {
+ // Not fatal: play video without audio
+ Log.w(TAG, tag + ": audio SETUP failed (" + r.code + "), no audio");
+ audio = false;
+ }
+ }
+ if (!audio) sdp.audio = new AudioConfig();
+
+ if (gen != generation) return false;
+ callback.onSdp(sdp);
+
+ // PLAY on the request URL, as before: accepted by the servers seen so far
+ r = request(in, out, "PLAY", u.requestUrl, cseq++,
+ common + "Session: " + session + "\r\n");
+ r.require("PLAY");
+
+ synchronized (lock) {
+ if (gen != generation) return false;
+ lastPacketMs = System.nanoTime() / 1_000_000;
+ streamBytes = 0;
+ bitrateMarkNs = System.nanoTime();
+ active = true;
+ }
+ status(gen, "buffering");
+ s.setSoTimeout(0); // the watchdog handles silence
+ receive(gen, in);
+ return true;
+ } finally {
+ synchronized (lock) {
+ if (socket == s) socket = null;
+ }
+ try {
+ s.close();
+ } catch (IOException ignored) {}
+ }
+ }
+
+ private void receive(int gen, BufferedInputStream in) throws IOException {
+ byte[] buf = new byte[65535 + 4];
+ boolean first = true;
+ while (active && gen == generation) {
+ int total = readInterleavedPacket(in, buf);
+ if (total < 0) throw new IOException("Connection closed by server");
+ streamBytes += total;
+ int channel = buf[1] & 0xFF;
+ if (gen != generation) break;
+ boolean usable = callback.onPacket(channel, buf, 4, total - 4);
+ // Only usable video keeps the session alive and counts as connected
+ if (channel == CHANNEL_VIDEO && usable) {
+ lastPacketMs = System.nanoTime() / 1_000_000;
+ if (first) {
+ first = false;
+ status(gen, "connected");
+ }
+ }
+ }
+ }
+
+ // ====================== Protocol helpers ======================
+
+ static final class Response {
+ int code;
+ final Map headers = new HashMap<>();
+ String body = "";
+
+ boolean ok() { return code >= 200 && code < 300; }
+
+ String header(String name) { return headers.get(name.toLowerCase(Locale.ROOT)); }
+
+ void require(String what) throws IOException {
+ if (!ok()) throw new IOException(what + " failed: " + code);
+ }
+ }
+
+ private Response request(InputStream in, OutputStream out, String method, String target,
+ int cseq, String headers) throws IOException {
+ String req = method + " " + target + " RTSP/1.0\r\n" +
+ "CSeq: " + cseq + "\r\n" + headers + "\r\n";
+ out.write(req.getBytes(StandardCharsets.UTF_8));
+ out.flush();
+ return readResponse(in);
+ }
+
+ /**
+ * Read one RTSP response: status line, headers and a Content-Length body. Interleaved
+ * RTP frames that some servers send before the PLAY reply are skipped.
+ */
+ static Response readResponse(InputStream in) throws IOException {
+ if (in.markSupported()) {
+ in.mark(1);
+ while (in.read() == '$') {
+ int channel = in.read();
+ int hi = in.read();
+ int lo = in.read();
+ if ((channel | hi | lo) < 0) throw new IOException("Connection closed");
+ skipFully(in, (hi << 8) | lo);
+ in.mark(1);
+ }
+ in.reset();
+ }
+
+ String status = readLine(in);
+ if (status == null) throw new IOException("Server closed connection during handshake");
+ Log.i(TAG, status);
+ String[] parts = status.split(" ", 3);
+ if (parts.length < 2 || !parts[0].startsWith("RTSP/")) {
+ throw new IOException("Malformed RTSP response: " + status);
+ }
+ Response r = new Response();
+ try {
+ r.code = Integer.parseInt(parts[1]);
+ } catch (NumberFormatException e) {
+ throw new IOException("Malformed RTSP status code: " + status);
+ }
+
+ String line;
+ while ((line = readLine(in)) != null && !line.isEmpty()) {
+ Log.d(TAG, line);
+ int colon = line.indexOf(':');
+ if (colon > 0) {
+ r.headers.put(line.substring(0, colon).trim().toLowerCase(Locale.ROOT),
+ line.substring(colon + 1).trim());
+ }
+ }
+
+ String length = r.header("content-length");
+ if (length != null) {
+ int len;
+ try {
+ len = Integer.parseInt(length);
+ } catch (NumberFormatException e) {
+ throw new IOException("Bad Content-Length: " + length);
+ }
+ if (len < 0 || len > 65536) throw new IOException("Bad Content-Length: " + len);
+ ByteArrayOutputStream body = new ByteArrayOutputStream(len);
+ byte[] chunk = new byte[Math.min(len, 4096)];
+ while (body.size() < len) {
+ int n = in.read(chunk, 0, Math.min(chunk.length, len - body.size()));
+ if (n < 0) throw new IOException("Truncated response body");
+ body.write(chunk, 0, n);
+ }
+ r.body = body.toString("UTF-8");
+ }
+ return r;
+ }
+
+ /** Read a body of unknown length: everything until 300 ms of silence, at most 64 KiB. */
+ private static String readUntilIdle(Socket s, InputStream in) throws IOException {
+ int timeout = s.getSoTimeout();
+ ByteArrayOutputStream body = new ByteArrayOutputStream();
+ byte[] chunk = new byte[4096];
+ s.setSoTimeout(300);
+ try {
+ while (body.size() < 65536) {
+ int n = in.read(chunk, 0, Math.min(chunk.length, 65536 - body.size()));
+ if (n < 0) break;
+ body.write(chunk, 0, n);
+ }
+ } catch (java.net.SocketTimeoutException ignored) {
+ // Line went quiet: the body is complete
+ } finally {
+ s.setSoTimeout(timeout);
+ }
+ return body.toString("UTF-8");
+ }
+
+ private static void skipFully(InputStream in, int len) throws IOException {
+ while (len > 0) {
+ long n = in.skip(len);
+ if (n <= 0) {
+ if (in.read() < 0) throw new IOException("Connection closed");
+ n = 1;
+ }
+ len -= (int) n;
+ }
+ }
+
+ /** Read a CRLF/LF-terminated line byte by byte; null at end of stream. */
+ static String readLine(InputStream in) throws IOException {
+ StringBuilder sb = new StringBuilder(128);
+ int c;
+ while ((c = in.read()) != -1) {
+ if (c == '\n') return sb.toString();
+ if (c != '\r') {
+ if (sb.length() >= 8192) throw new IOException("RTSP header line too long");
+ sb.append((char) c);
+ }
+ }
+ return sb.length() > 0 ? sb.toString() : null;
+ }
+
+ /** Read one RTP-over-TCP interleaved packet into pktBuf; -1 on a clean end of stream. */
+ static int readInterleavedPacket(BufferedInputStream input, byte[] pktBuf) throws IOException {
+ while (true) {
+ int b0 = input.read();
+ if (b0 < 0) return -1;
+ if (b0 != '$') continue;
+ int b1 = input.read();
+ int b2 = input.read();
+ int b3 = input.read();
+ if ((b1 | b2 | b3) < 0) throw new IOException("Truncated interleaved header");
+ int len = (b2 << 8) | b3;
+ if (len + 4 > pktBuf.length) throw new IOException("RTP packet too large: " + len);
+ pktBuf[0] = '$';
+ pktBuf[1] = (byte) b1;
+ pktBuf[2] = (byte) b2;
+ pktBuf[3] = (byte) b3;
+ int offset = 4;
+ int remaining = len;
+ while (remaining > 0) {
+ int n = input.read(pktBuf, offset, remaining);
+ if (n < 0) throw new IOException("Truncated RTP body");
+ remaining -= n;
+ offset += n;
+ }
+ return len + 4;
+ }
+ }
+
+ /** Length of the RTP header including CSRC list and extension, or -1 if malformed. */
+ static int rtpHeaderLength(byte[] data, int length) {
+ return rtpHeaderLength(data, 0, length);
+ }
+
+ static int rtpHeaderLength(byte[] data, int offset, int length) {
+ if (length < 12 || (data[offset] & 0xC0) != 0x80) return -1;
+ int len = 12 + 4 * (data[offset] & 0x0F);
+ if ((data[offset] & 0x10) != 0) {
+ if (len + 4 > length) return -1;
+ int extWords = ((data[offset + len + 2] & 0xFF) << 8) | (data[offset + len + 3] & 0xFF);
+ len += 4 + 4 * extWords;
+ }
+ return len <= length ? len : -1;
+ }
+
+ /** Payload end: packet length minus RTP padding, or -1 if malformed. */
+ static int rtpPayloadEnd(byte[] data, int length) {
+ if ((data[0] & 0x20) == 0) return length;
+ int pad = data[length - 1] & 0xFF;
+ return pad <= length ? length - pad : -1;
+ }
+
+ // ====================== URL ======================
+
+ /** rtsp://[user[:password]@]host[:port][/path][?query], parsed leniently. */
+ static final class Url {
+ String host;
+ int port = 554;
+ /** Decoded "user:password", or null. */
+ String userInfo;
+ /** URL without credentials, used in requests. */
+ String requestUrl;
+
+ static Url parse(String url) throws IOException {
+ if (url == null) throw new IOException("Camera slot not configured");
+ url = url.trim();
+ int schemeEnd = url.indexOf("://");
+ if (schemeEnd <= 0) throw new IOException("Invalid RTSP URL: " + url);
+ String scheme = url.substring(0, schemeEnd).toLowerCase(Locale.ROOT);
+ int authStart = schemeEnd + 3;
+ int pathStart = authStart;
+ while (pathStart < url.length() && "/?#".indexOf(url.charAt(pathStart)) < 0) {
+ pathStart++;
+ }
+ String authority = url.substring(authStart, pathStart);
+ String rest = url.substring(pathStart);
+ int fragment = rest.indexOf('#');
+ if (fragment >= 0) rest = rest.substring(0, fragment);
+
+ Url u = new Url();
+ // The last '@' separates credentials; passwords may contain '@'
+ int at = authority.lastIndexOf('@');
+ if (at >= 0) {
+ u.userInfo = percentDecode(authority.substring(0, at));
+ authority = authority.substring(at + 1);
+ }
+ String hostPort = authority;
+ if (hostPort.startsWith("[")) {
+ int close = hostPort.indexOf(']');
+ if (close < 0) throw new IOException("Invalid IPv6 host: " + url);
+ u.host = hostPort.substring(1, close);
+ hostPort = hostPort.substring(close + 1);
+ if (hostPort.startsWith(":")) u.port = parsePort(hostPort.substring(1), url);
+ } else {
+ int colon = hostPort.lastIndexOf(':');
+ if (colon >= 0) {
+ u.port = parsePort(hostPort.substring(colon + 1), url);
+ hostPort = hostPort.substring(0, colon);
+ }
+ u.host = hostPort;
+ }
+ if (u.host.isEmpty()) throw new IOException("Invalid RTSP URL: host is missing");
+ u.requestUrl = scheme + "://" + authority + rest;
+ return u;
+ }
+
+ private static int parsePort(String s, String url) throws IOException {
+ if (s.isEmpty()) return 554;
+ try {
+ int p = Integer.parseInt(s);
+ if (p > 0 && p < 65536) return p;
+ } catch (NumberFormatException ignored) {}
+ throw new IOException("Invalid port in URL: " + url);
+ }
+
+ private static String percentDecode(String s) {
+ if (s.indexOf('%') < 0) return s;
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (c == '%' && i + 2 < s.length()
+ && Character.digit(s.charAt(i + 1), 16) >= 0
+ && Character.digit(s.charAt(i + 2), 16) >= 0) {
+ out.write(Integer.parseInt(s.substring(i + 1, i + 3), 16));
+ i += 2;
+ } else {
+ byte[] b = String.valueOf(c).getBytes(StandardCharsets.UTF_8);
+ out.write(b, 0, b.length);
+ }
+ }
+ return new String(out.toByteArray(), StandardCharsets.UTF_8);
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/openipc/decoder/Sdp.java b/android/app/src/main/java/com/openipc/decoder/Sdp.java
new file mode 100644
index 00000000..34ed629b
--- /dev/null
+++ b/android/app/src/main/java/com/openipc/decoder/Sdp.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
+ *
+ * Sdp.java — session description parsed from a DESCRIBE response
+ *
+ */
+
+package com.openipc.decoder;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+class Sdp {
+ /** Legacy payload types, used when the SDP has no rtpmap for the video track. */
+ static final int PT_H264 = 96;
+ static final int PT_H265 = 97;
+
+ String videoUrl;
+ /** Null when the stream has no audio track. */
+ String audioUrl;
+ int videoPt = -1;
+ boolean videoH265;
+ AudioConfig audio = new AudioConfig();
+
+ /**
+ * Parse an SDP. {@code base} is the Content-Base of the DESCRIBE response, or the
+ * request URL when the server did not send one.
+ */
+ static Sdp parse(String sdp, String base) {
+ Sdp out = new Sdp();
+ String session = null;
+ String audioControl = null;
+ int media = -1; // -1 session level, 0 video, 1 audio, 2 other
+ boolean hasAudio = false;
+ // Each m=video section keeps its own payloads, rtpmap and control
+ List videos = new ArrayList<>();
+ VideoSection video = null;
+ AudioConfig audio = out.audio;
+
+ for (String line : sdp.split("[\r\n]+")) {
+ if (line.startsWith("m=")) {
+ String[] parts = line.split("\\s+");
+ if (line.startsWith("m=video")) {
+ media = 0;
+ video = new VideoSection();
+ videos.add(video);
+ for (int i = 3; i < parts.length; i++) {
+ try {
+ video.pts.add(Integer.parseInt(parts[i]));
+ } catch (NumberFormatException ignored) {}
+ }
+ } else if (line.startsWith("m=audio")) {
+ media = 1;
+ hasAudio = true;
+ if (parts.length >= 4) {
+ try {
+ audio.pt = Integer.parseInt(parts[3]);
+ audio.applyStaticPayloadType();
+ } catch (NumberFormatException ignored) {}
+ }
+ } else {
+ media = 2;
+ }
+ } else if (line.startsWith("a=control:")) {
+ String ctrl = line.substring("a=control:".length()).trim();
+ if (media == -1) {
+ session = ctrl;
+ } else if (media == 0) {
+ video.control = ctrl;
+ } else if (media == 1 && audioControl == null) {
+ audioControl = ctrl;
+ }
+ } else if (media == 0 && line.startsWith("a=rtpmap:")) {
+ // a=rtpmap: /
+ String[] parts = line.substring("a=rtpmap:".length()).trim().split("[\\s/]+");
+ if (parts.length >= 2) {
+ try {
+ video.codecs.put(Integer.parseInt(parts[0]),
+ parts[1].toUpperCase(Locale.ROOT));
+ } catch (NumberFormatException ignored) {}
+ }
+ } else if (media == 1 && audio.pt >= 0
+ && line.startsWith("a=rtpmap:" + audio.pt + " ")) {
+ audio.applyRtpmap(line.substring(("a=rtpmap:" + audio.pt + " ").length()));
+ } else if (media == 1 && audio.pt >= 0
+ && line.startsWith("a=fmtp:" + audio.pt + " ")) {
+ audio.applyFmtp(line.substring(("a=fmtp:" + audio.pt + " ").length()));
+ }
+ }
+ audio.finish();
+
+ // Video: the first section offering H.264/H.265 (legacy 96/97 without rtpmap);
+ // payload type and control URL always come from that same section
+ String sessionBase = resolve(base, session);
+ String videoControl = null;
+ outer:
+ for (VideoSection v : videos) {
+ for (int pt : v.pts) {
+ String codec = v.codecs.get(pt);
+ if (codec == null) {
+ codec = pt == PT_H265 ? "H265" : pt == PT_H264 ? "H264" : null;
+ }
+ if ("H264".equals(codec) || "H265".equals(codec) || "HEVC".equals(codec)) {
+ out.videoPt = pt;
+ out.videoH265 = !"H264".equals(codec);
+ videoControl = v.control;
+ break outer;
+ }
+ }
+ }
+ out.videoUrl = resolve(sessionBase, videoControl != null ? videoControl : "trackID=0");
+ if (hasAudio) {
+ out.audioUrl = resolve(sessionBase, audioControl != null ? audioControl : "trackID=1");
+ }
+ return out;
+ }
+
+ private static final class VideoSection {
+ final List pts = new ArrayList<>();
+ final Map codecs = new HashMap<>();
+ String control;
+ }
+
+ /** Resolve an a=control value against a base URL (RFC 2326 C.1.1). */
+ static String resolve(String base, String control) {
+ if (control == null || control.isEmpty() || control.equals("*")) return base;
+ String lower = control.toLowerCase(Locale.ROOT);
+ if (lower.startsWith("rtsp://") || lower.startsWith("rtsps://")) return control;
+ if (control.startsWith("/")) {
+ // Root-relative: keep scheme://authority of the base
+ int authorityStart = base.indexOf("://");
+ int pathStart = authorityStart < 0 ? -1 : base.indexOf('/', authorityStart + 3);
+ return (pathStart < 0 ? base : base.substring(0, pathStart)) + control;
+ }
+ return base.endsWith("/") ? base + control : base + "/" + control;
+ }
+}
diff --git a/android/app/src/test/java/com/openipc/decoder/RtpTest.java b/android/app/src/test/java/com/openipc/decoder/RtpTest.java
index 7890eb84..4df0649d 100644
--- a/android/app/src/test/java/com/openipc/decoder/RtpTest.java
+++ b/android/app/src/test/java/com/openipc/decoder/RtpTest.java
@@ -21,24 +21,24 @@ public class RtpTest {
public void headerLengthWithCsrcAndExtension() {
byte[] p = new byte[40];
p[0] = (byte) 0x80;
- assertEquals(12, RtspClient.rtpHeaderLength(p, 40));
+ assertEquals(12, RtspSession.rtpHeaderLength(p, 40));
p[0] = (byte) 0x82; // 2 CSRC
- assertEquals(20, RtspClient.rtpHeaderLength(p, 40));
+ assertEquals(20, RtspSession.rtpHeaderLength(p, 40));
p[0] = (byte) 0x90; // extension, length 2 words at bytes 14..15
p[15] = 2;
- assertEquals(24, RtspClient.rtpHeaderLength(p, 40));
+ assertEquals(24, RtspSession.rtpHeaderLength(p, 40));
}
@Test
public void malformedHeaders() {
byte[] p = new byte[40];
p[0] = (byte) 0x40; // version 1
- assertEquals(-1, RtspClient.rtpHeaderLength(p, 40));
+ assertEquals(-1, RtspSession.rtpHeaderLength(p, 40));
p[0] = (byte) 0x80;
- assertEquals(-1, RtspClient.rtpHeaderLength(p, 11));
+ assertEquals(-1, RtspSession.rtpHeaderLength(p, 11));
p[0] = (byte) 0x90;
p[15] = 100; // extension longer than the packet
- assertEquals(-1, RtspClient.rtpHeaderLength(p, 40));
+ assertEquals(-1, RtspSession.rtpHeaderLength(p, 40));
}
@Test
@@ -46,9 +46,9 @@ public void paddingIsRemovedFromPayloadEnd() {
byte[] p = new byte[40];
p[0] = (byte) 0xA0;
p[39] = 4;
- assertEquals(36, RtspClient.rtpPayloadEnd(p, 40));
+ assertEquals(36, RtspSession.rtpPayloadEnd(p, 40));
p[0] = (byte) 0x80;
- assertEquals(40, RtspClient.rtpPayloadEnd(p, 40));
+ assertEquals(40, RtspSession.rtpPayloadEnd(p, 40));
}
private static BufferedInputStream stream(int... bytes) {
@@ -61,7 +61,7 @@ private static BufferedInputStream stream(int... bytes) {
public void interleavedPacketAfterGarbage() throws IOException {
byte[] buf = new byte[65535];
// 'R' 'T' garbage, then $ channel 2 length 3
- int total = RtspClient.readInterleavedPacket(stream('R', 'T', '$', 2, 0, 3, 7, 8, 9), buf);
+ int total = RtspSession.readInterleavedPacket(stream('R', 'T', '$', 2, 0, 3, 7, 8, 9), buf);
assertEquals(7, total);
assertEquals(2, buf[1]);
assertEquals(9, buf[6]);
@@ -70,9 +70,9 @@ public void interleavedPacketAfterGarbage() throws IOException {
@Test
public void cleanEofAndTruncation() throws IOException {
byte[] buf = new byte[65535];
- assertEquals(-1, RtspClient.readInterleavedPacket(stream(), buf));
- assertThrows(IOException.class, () -> RtspClient.readInterleavedPacket(stream('$', 0), buf));
+ assertEquals(-1, RtspSession.readInterleavedPacket(stream(), buf));
+ assertThrows(IOException.class, () -> RtspSession.readInterleavedPacket(stream('$', 0), buf));
assertThrows(IOException.class,
- () -> RtspClient.readInterleavedPacket(stream('$', 0, 0, 5, 1, 2), buf));
+ () -> RtspSession.readInterleavedPacket(stream('$', 0, 0, 5, 1, 2), buf));
}
}
diff --git a/android/app/src/test/java/com/openipc/decoder/RtspResponseTest.java b/android/app/src/test/java/com/openipc/decoder/RtspResponseTest.java
index ed204bff..6aaaefca 100644
--- a/android/app/src/test/java/com/openipc/decoder/RtspResponseTest.java
+++ b/android/app/src/test/java/com/openipc/decoder/RtspResponseTest.java
@@ -10,9 +10,11 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
import org.junit.Test;
+import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
@@ -23,31 +25,53 @@ private static InputStream in(String s) {
return new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8));
}
+ private static RtspSession.Response read(String s) throws IOException {
+ return RtspSession.readResponse(new BufferedInputStream(in(s)));
+ }
+
+ @Test
+ public void headersAreCaseInsensitive() throws IOException {
+ RtspSession.Response r = read(
+ "RTSP/1.0 200 OK\r\nCSeq: 2\r\nsession: 12345678;timeout=60\r\n\r\n");
+ assertTrue(r.ok());
+ assertEquals("12345678;timeout=60", r.header("Session"));
+ assertNull(r.header("Content-Base"));
+ }
+
+ @Test
+ public void bodyIsReadByContentLength() throws IOException {
+ InputStream s = new BufferedInputStream(in(
+ "RTSP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nv=0\r\n$rest"));
+ RtspSession.Response r = RtspSession.readResponse(s);
+ assertEquals("v=0\r\n", r.body);
+ assertEquals('$', s.read()); // the stream continues right after the body
+ }
+
@Test
- public void returnsRequestedHeaderValueWithoutParameters() throws IOException {
- String session = RtspClient.readRtspResponse(
- in("RTSP/1.0 200 OK\r\nCSeq: 2\r\nsession: 12345678;timeout=60\r\n\r\n"),
- "Session:");
- assertEquals("12345678", session);
+ public void interleavedFramesBeforeResponseAreSkipped() throws IOException {
+ RtspSession.Response r = read("$\u0000\u0000\u0003abc$\u0001\u0000\u0001xRTSP/1.0 200 OK\r\n\r\n");
+ assertEquals(200, r.code);
}
@Test
- public void missingHeaderIsNull() throws IOException {
- assertNull(RtspClient.readRtspResponse(in("RTSP/1.0 200 OK\r\nCSeq: 2\r\n\r\n"), "Session:"));
+ public void errorStatusIsNotOk() throws IOException {
+ RtspSession.Response r = read("RTSP/1.0 401 Unauthorized\r\n\r\n");
+ assertEquals(401, r.code);
+ assertThrows(IOException.class, () -> r.require("DESCRIBE"));
}
@Test
- public void errorAndMalformedStatusThrow() {
- assertThrows(IOException.class, () -> RtspClient.readRtspResponse(
- in("RTSP/1.0 401 Unauthorized\r\n\r\n"), null));
- assertThrows(IOException.class, () -> RtspClient.readRtspResponse(in("garbage\r\n\r\n"), null));
- assertThrows(IOException.class, () -> RtspClient.readRtspResponse(in(""), null));
+ public void malformedResponsesThrow() {
+ assertThrows(IOException.class, () -> read("garbage\r\n\r\n"));
+ assertThrows(IOException.class, () -> read(""));
+ assertThrows(IOException.class, () -> read("RTSP/1.0 200 OK\r\nContent-Length: -1\r\n\r\n"));
+ assertThrows(IOException.class, () -> read("RTSP/1.0 200 OK\r\nContent-Length: 10\r\n\r\nshort"));
}
@Test
public void tooLongLineIsRejected() {
StringBuilder sb = new StringBuilder("RTSP/1.0 200 OK\r\nX: ");
for (int i = 0; i < 9000; i++) sb.append('a');
- assertThrows(IOException.class, () -> RtspClient.readRtspResponse(in(sb + "\r\n\r\n"), null));
+ assertThrows(IOException.class, () -> read(sb + "\r\n\r\n"));
}
}
diff --git a/android/app/src/test/java/com/openipc/decoder/RtspSessionTest.java b/android/app/src/test/java/com/openipc/decoder/RtspSessionTest.java
new file mode 100644
index 00000000..1b489b50
--- /dev/null
+++ b/android/app/src/test/java/com/openipc/decoder/RtspSessionTest.java
@@ -0,0 +1,344 @@
+/*
+ * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
+ *
+ * RtspSessionTest.java — RtspSession against a scripted RTSP server on localhost
+ *
+ */
+
+package com.openipc.decoder;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class RtspSessionTest {
+ private static final String SDP_AV =
+ "v=0\r\n" +
+ "m=video 0 RTP/AVP 96\r\na=rtpmap:96 H265/90000\r\na=control:trackID=0\r\n" +
+ "m=audio 0 RTP/AVP 98\r\na=rtpmap:98 L16/16000\r\na=control:trackID=1\r\n";
+ private static final String SDP_VIDEO_ONLY =
+ "v=0\r\nm=video 0 RTP/AVP 96\r\na=rtpmap:96 H264/90000\r\na=control:trackID=0\r\n";
+
+ /** Scripted RTSP server: answers each connection with the configured behaviour. */
+ private static final class FakeServer implements AutoCloseable {
+ final ServerSocket server;
+ final List requests = Collections.synchronizedList(new ArrayList<>());
+ final AtomicInteger connections = new AtomicInteger();
+ volatile String sdp = SDP_AV;
+ volatile int audioSetupStatus = 200;
+ /** Connections closed right after accept, before any reply. */
+ volatile int failFirst;
+ /** Close the connection after sending the packets instead of staying open. */
+ volatile boolean closeAfterPackets;
+ volatile int videoPackets = 3;
+ /** Send the DESCRIBE body without a Content-Length header. */
+ volatile boolean omitContentLength;
+ volatile int audioPackets = 1;
+
+ FakeServer() throws IOException {
+ server = new ServerSocket(0, 50, InetAddress.getLoopbackAddress());
+ Thread t = new Thread(this::acceptLoop, "fake-rtsp");
+ t.setDaemon(true);
+ t.start();
+ }
+
+ String url(String credentials) {
+ return "rtsp://" + (credentials != null ? credentials + "@" : "")
+ + "127.0.0.1:" + server.getLocalPort() + "/stream=0";
+ }
+
+ private void acceptLoop() {
+ while (!server.isClosed()) {
+ try {
+ Socket s = server.accept();
+ int n = connections.incrementAndGet();
+ Thread t = new Thread(() -> serve(s, n));
+ t.setDaemon(true);
+ t.start();
+ } catch (IOException e) {
+ return;
+ }
+ }
+ }
+
+ private void serve(Socket s, int n) {
+ try (Socket socket = s) {
+ if (n <= failFirst) return;
+ InputStream in = new BufferedInputStream(socket.getInputStream());
+ OutputStream out = socket.getOutputStream();
+ while (true) {
+ String line = RtspSession.readLine(in);
+ if (line == null) return;
+ String requestLine = line;
+ String cseq = "0";
+ StringBuilder headers = new StringBuilder();
+ while ((line = RtspSession.readLine(in)) != null && !line.isEmpty()) {
+ headers.append(line).append('\n');
+ if (line.startsWith("CSeq:")) cseq = line.substring(5).trim();
+ }
+ requests.add(requestLine + "\n" + headers);
+ String method = requestLine.split(" ")[0];
+ String reply;
+ switch (method) {
+ case "DESCRIBE":
+ byte[] body = sdp.getBytes(StandardCharsets.UTF_8);
+ reply = "RTSP/1.0 200 OK\r\nCSeq: " + cseq + "\r\nContent-Type: "
+ + "application/sdp\r\n"
+ + (omitContentLength ? "" : "Content-Length: " + body.length + "\r\n")
+ + "\r\n" + sdp;
+ break;
+ case "SETUP":
+ int code = requestLine.contains("trackID=1") ? audioSetupStatus : 200;
+ reply = "RTSP/1.0 " + code + (code == 200 ? " OK" : " Not Found")
+ + "\r\nCSeq: " + cseq + "\r\nSession: ABC123;timeout=60\r\n\r\n";
+ break;
+ default:
+ reply = "RTSP/1.0 200 OK\r\nCSeq: " + cseq + "\r\n\r\n";
+ break;
+ }
+ out.write(reply.getBytes(StandardCharsets.UTF_8));
+ out.flush();
+ if (method.equals("PLAY")) {
+ for (int i = 0; i < videoPackets; i++) out.write(packet(0, 96));
+ for (int i = 0; i < audioPackets; i++) out.write(packet(2, 98));
+ out.flush();
+ if (closeAfterPackets) return;
+ // Keep the session open until the client goes away
+ while (in.read() >= 0) { /* ignore */ }
+ return;
+ }
+ }
+ } catch (IOException ignored) {
+ }
+ }
+
+ static byte[] packet(int channel, int pt) {
+ byte[] p = new byte[4 + 14];
+ p[0] = '$';
+ p[1] = (byte) channel;
+ p[3] = 14;
+ p[4] = (byte) 0x80;
+ p[5] = (byte) pt;
+ p[16] = 0x40;
+ p[17] = 0x01;
+ return p;
+ }
+
+ List methods() {
+ List m = new ArrayList<>();
+ synchronized (requests) {
+ for (String r : requests) m.add(r.substring(0, r.indexOf('\n')));
+ }
+ return m;
+ }
+
+ @Override
+ public void close() throws IOException {
+ server.close();
+ }
+ }
+
+ /** Records callbacks. */
+ private static final class Recorder implements RtspSession.Callback {
+ final List statuses = Collections.synchronizedList(new ArrayList<>());
+ final AtomicInteger video = new AtomicInteger();
+ final AtomicInteger audio = new AtomicInteger();
+ volatile Sdp sdp;
+ final AtomicInteger sdpCount = new AtomicInteger();
+ volatile CountDownLatch videoLatch = new CountDownLatch(3);
+ /** What onPacket reports for video packets. */
+ volatile boolean usable = true;
+
+ @Override public void onStatus(String status) { statuses.add(status); }
+
+ @Override public void onSdp(Sdp s) {
+ sdp = s;
+ sdpCount.incrementAndGet();
+ }
+
+ @Override public boolean onPacket(int channel, byte[] buf, int offset, int length) {
+ if (channel == RtspSession.CHANNEL_VIDEO) {
+ video.incrementAndGet();
+ videoLatch.countDown();
+ return usable;
+ } else if (channel == RtspSession.CHANNEL_AUDIO) {
+ audio.incrementAndGet();
+ }
+ return false;
+ }
+ }
+
+ private FakeServer server;
+ private RtspSession session;
+ private final Recorder rec = new Recorder();
+
+ @Before
+ public void setUp() throws IOException {
+ RtspSession.minRetryMs = 5;
+ RtspSession.maxRetryMs = 20;
+ RtspSession.watchdogMs = 8000;
+ server = new FakeServer();
+ session = new RtspSession("test", rec);
+ }
+
+ @After
+ public void tearDown() throws IOException {
+ session.stop();
+ server.close();
+ RtspSession.minRetryMs = 1000;
+ RtspSession.maxRetryMs = 30000;
+ RtspSession.watchdogMs = 8000;
+ }
+
+ private void runUntilVideo(String url, boolean withAudio) throws InterruptedException {
+ session.configure(url, "User-Agent: test\r\n", withAudio);
+ session.start();
+ assertTrue("video packets received", rec.videoLatch.await(5, TimeUnit.SECONDS));
+ }
+
+ @Test
+ public void handshakeWithAudio() throws Exception {
+ runUntilVideo(server.url("root:12345"), true);
+
+ String base = "rtsp://127.0.0.1:" + server.server.getLocalPort() + "/stream=0";
+ List m = server.methods();
+ assertEquals("DESCRIBE " + base + " RTSP/1.0", m.get(0));
+ assertEquals("SETUP " + base + "/trackID=0 RTSP/1.0", m.get(1));
+ assertEquals("SETUP " + base + "/trackID=1 RTSP/1.0", m.get(2));
+ assertEquals("PLAY " + base + " RTSP/1.0", m.get(3));
+
+ String describe = server.requests.get(0);
+ assertTrue(describe.contains("Authorization: Basic"));
+ assertTrue(describe.contains("User-Agent: test"));
+ assertTrue(server.requests.get(3).contains("Session: ABC123"));
+
+ assertTrue(rec.sdp.videoH265);
+ assertEquals(AudioConfig.PCM, rec.sdp.audio.codec);
+ assertTrue(rec.statuses.contains("buffering"));
+ assertTrue(rec.statuses.contains("connected"));
+ assertTrue(session.isActive());
+ Thread.sleep(100);
+ assertEquals(1, rec.audio.get());
+ }
+
+ @Test
+ public void noAudioTrackMeansNoAudioSetup() throws Exception {
+ // #18
+ server.sdp = SDP_VIDEO_ONLY;
+ runUntilVideo(server.url(null), true);
+ List m = server.methods();
+ assertEquals(3, m.size());
+ assertTrue(m.get(2).startsWith("PLAY"));
+ assertFalse(rec.sdp.videoH265);
+ assertEquals(AudioConfig.NONE, rec.sdp.audio.codec);
+ }
+
+ @Test
+ public void failedAudioSetupIsNotFatal() throws Exception {
+ // #18
+ server.audioSetupStatus = 404;
+ runUntilVideo(server.url(null), true);
+ assertTrue(server.methods().get(3).startsWith("PLAY"));
+ assertEquals(AudioConfig.NONE, rec.sdp.audio.codec);
+ assertEquals(1, server.connections.get());
+ }
+
+ @Test
+ public void quadModeSkipsAudio() throws Exception {
+ runUntilVideo(server.url(null), false);
+ List m = server.methods();
+ assertEquals(3, m.size());
+ assertEquals(AudioConfig.NONE, rec.sdp.audio.codec);
+ }
+
+ @Test
+ public void neverGivesUpAfterManyFailures() throws Exception {
+ // #27: more than 10 failed attempts in a row, then the camera comes back
+ server.failFirst = 15;
+ runUntilVideo(server.url(null), true);
+ assertTrue(server.connections.get() > 15);
+ }
+
+ @Test
+ public void reconnectsWhenServerClosesStream() throws Exception {
+ server.closeAfterPackets = true;
+ rec.videoLatch = new CountDownLatch(9); // 3 packets per session, 3 sessions
+ runUntilVideo(server.url(null), true);
+ assertTrue(server.connections.get() >= 3);
+ assertTrue(rec.statuses.contains("disconnected"));
+ }
+
+ @Test
+ public void stopIsNonBlockingAndSilent() throws Exception {
+ runUntilVideo(server.url(null), true);
+ long t0 = System.nanoTime();
+ session.stop();
+ assertTrue("stop() returns quickly", System.nanoTime() - t0 < 200_000_000L);
+ assertFalse(session.isActive());
+ int sdpCount = rec.sdpCount.get();
+ int statusCount = rec.statuses.size();
+ Thread.sleep(300);
+ assertEquals("no reconnect after stop", sdpCount, rec.sdpCount.get());
+ assertEquals("no status after stop", statusCount, rec.statuses.size());
+ }
+
+ @Test
+ public void describeWithoutContentLength() throws Exception {
+ server.omitContentLength = true;
+ runUntilVideo(server.url(null), true);
+ assertTrue(rec.sdp.videoH265);
+ assertTrue(server.methods().get(1).startsWith("SETUP"));
+ }
+
+ @Test
+ public void noSupportedVideoTrackFailsAndRetries() throws Exception {
+ server.sdp = "v=0\r\nm=video 0 RTP/AVP 26\r\na=rtpmap:26 JPEG/90000\r\n";
+ session.configure(server.url(null), "", true);
+ session.start();
+ long deadline = System.nanoTime() + 3_000_000_000L;
+ while (server.connections.get() < 3 && System.nanoTime() < deadline) Thread.sleep(10);
+ assertTrue("retried", server.connections.get() >= 3);
+ assertEquals("no SETUP without a usable video track", 0,
+ server.methods().stream().filter(m -> m.startsWith("SETUP")).count());
+ assertEquals(0, rec.sdpCount.get());
+ }
+
+ @Test
+ public void unusableVideoDoesNotKeepSessionAlive() throws Exception {
+ RtspSession.watchdogMs = 200;
+ rec.usable = false;
+ rec.videoLatch = new CountDownLatch(6); // packets from at least two sessions
+ runUntilVideo(server.url(null), true);
+ assertTrue("watchdog reconnected", server.connections.get() >= 2);
+ assertFalse("never connected", rec.statuses.contains("connected"));
+ }
+
+ @Test
+ public void retryDelayNeverExceedsMaximum() {
+ RtspSession.maxRetryMs = 30000;
+ for (int i = 0; i < 1000; i++) {
+ assertTrue(RtspSession.retryDelay(30000) <= 30000);
+ long d = RtspSession.retryDelay(1000);
+ assertTrue(d >= 1000 && d <= 1500);
+ }
+ }
+}
diff --git a/android/app/src/test/java/com/openipc/decoder/RtspUrlTest.java b/android/app/src/test/java/com/openipc/decoder/RtspUrlTest.java
new file mode 100644
index 00000000..244d828e
--- /dev/null
+++ b/android/app/src/test/java/com/openipc/decoder/RtspUrlTest.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
+ *
+ * RtspUrlTest.java — lenient rtsp:// URL parsing
+ *
+ */
+
+package com.openipc.decoder;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+
+import org.junit.Test;
+
+import java.io.IOException;
+
+public class RtspUrlTest {
+ @Test
+ public void credentialsAreStrippedFromRequestUrl() throws IOException {
+ RtspSession.Url u = RtspSession.Url.parse("rtsp://root:12345@192.168.1.10:8554/stream=0?a=1");
+ assertEquals("192.168.1.10", u.host);
+ assertEquals(8554, u.port);
+ assertEquals("root:12345", u.userInfo);
+ assertEquals("rtsp://192.168.1.10:8554/stream=0?a=1", u.requestUrl);
+ }
+
+ @Test
+ public void defaultsAndNoCredentials() throws IOException {
+ RtspSession.Url u = RtspSession.Url.parse("rtsp://cam");
+ assertEquals("cam", u.host);
+ assertEquals(554, u.port);
+ assertNull(u.userInfo);
+ assertEquals("rtsp://cam", u.requestUrl);
+ }
+
+ @Test
+ public void passwordWithAtSignAndPercentEncoding() throws IOException {
+ assertEquals("admin:p@ss", RtspSession.Url.parse("rtsp://admin:p@ss@cam/s").userInfo);
+ assertEquals("admin:p@ss w", RtspSession.Url.parse("rtsp://admin:p%40ss%20w@cam/s").userInfo);
+ }
+
+ @Test
+ public void ipv6Host() throws IOException {
+ RtspSession.Url u = RtspSession.Url.parse("rtsp://u:p@[fe80::1]:10554/live");
+ assertEquals("fe80::1", u.host);
+ assertEquals(10554, u.port);
+ assertEquals("rtsp://[fe80::1]:10554/live", u.requestUrl);
+ }
+
+ @Test
+ public void invalidUrls() {
+ assertThrows(IOException.class, () -> RtspSession.Url.parse(null));
+ assertThrows(IOException.class, () -> RtspSession.Url.parse("192.168.1.10/stream"));
+ assertThrows(IOException.class, () -> RtspSession.Url.parse("rtsp://:554/s"));
+ assertThrows(IOException.class, () -> RtspSession.Url.parse("rtsp://cam:99999/s"));
+ }
+}
diff --git a/android/app/src/test/java/com/openipc/decoder/SdpParserTest.java b/android/app/src/test/java/com/openipc/decoder/SdpParserTest.java
index dd052369..a387ec58 100644
--- a/android/app/src/test/java/com/openipc/decoder/SdpParserTest.java
+++ b/android/app/src/test/java/com/openipc/decoder/SdpParserTest.java
@@ -9,6 +9,7 @@
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
@@ -28,19 +29,19 @@ public class SdpParserTest {
"IndexLength=3; IndexDeltaLength=3\r\n" +
"a=control:trackID=1\r\n";
- private static String[] parse(String sdp, String base, boolean[] h265, AudioConfig audio) {
- return RtspClient.parseSdp(sdp, base, h265, audio);
+ private static Sdp parse(String sdp, String base) {
+ return Sdp.parse(sdp, base);
}
@Test
public void videoAndAacTrack() {
- boolean[] h265 = new boolean[1];
- AudioConfig a = new AudioConfig();
- String[] urls = parse(OPENIPC_LIKE, "rtsp://cam/stream=0", h265, a);
+ Sdp sdp = parse(OPENIPC_LIKE, "rtsp://cam/stream=0");
+ AudioConfig a = sdp.audio;
- assertTrue(h265[0]);
- assertEquals("rtsp://cam/stream=0/trackID=0", urls[0]);
- assertEquals("rtsp://cam/stream=0/trackID=1", urls[1]);
+ assertTrue(sdp.videoH265);
+ assertEquals(97, sdp.videoPt);
+ assertEquals("rtsp://cam/stream=0/trackID=0", sdp.videoUrl);
+ assertEquals("rtsp://cam/stream=0/trackID=1", sdp.audioUrl);
assertEquals(AudioConfig.AAC, a.codec);
assertEquals(98, a.pt);
assertEquals(48000, a.sampleRate);
@@ -53,10 +54,9 @@ public void videoAndAacTrack() {
@Test
public void fmtpBeforeRtpmapKeepsAuHeaderLayout() {
- AudioConfig a = new AudioConfig();
- parse("m=audio 0 RTP/AVP 98\r\n" +
+ AudioConfig a = parse("m=audio 0 RTP/AVP 98\r\n" +
"a=fmtp:98 config=1408; sizeLength=13; indexLength=3; indexDeltaLength=3\r\n" +
- "a=rtpmap:98 MPEG4-GENERIC/16000/1\r\n", "rtsp://x", null, a);
+ "a=rtpmap:98 MPEG4-GENERIC/16000/1\r\n", "rtsp://x").audio;
assertEquals(AudioConfig.AAC, a.codec);
assertEquals(13, a.sizeLength);
assertEquals(16000, a.sampleRate);
@@ -64,35 +64,30 @@ public void fmtpBeforeRtpmapKeepsAuHeaderLayout() {
@Test
public void invalidAuHeaderLayoutDisablesAac() {
- AudioConfig a = new AudioConfig();
- parse("m=audio 0 RTP/AVP 98\r\na=rtpmap:98 MPEG4-GENERIC/16000\r\n" +
- "a=fmtp:98 sizelength=13; indexlength=-3\r\n", "rtsp://x", null, a);
+ AudioConfig a = parse("m=audio 0 RTP/AVP 98\r\na=rtpmap:98 MPEG4-GENERIC/16000\r\n" +
+ "a=fmtp:98 sizelength=13; indexlength=-3\r\n", "rtsp://x").audio;
assertEquals(AudioConfig.NONE, a.codec);
- AudioConfig b = new AudioConfig();
- parse("m=audio 0 RTP/AVP 98\r\na=rtpmap:98 MPEG4-GENERIC/16000\r\n" +
- "a=fmtp:98 sizelength=40\r\n", "rtsp://x", null, b);
+ AudioConfig b = parse("m=audio 0 RTP/AVP 98\r\na=rtpmap:98 MPEG4-GENERIC/16000\r\n" +
+ "a=fmtp:98 sizelength=40\r\n", "rtsp://x").audio;
assertEquals(AudioConfig.NONE, b.codec);
}
@Test
public void staticPayloadTypesWithoutRtpmap() {
- AudioConfig a = new AudioConfig();
- parse("m=video 0 RTP/AVP 96\r\na=rtpmap:96 H264/90000\r\nm=audio 0 RTP/AVP 8\r\n",
- "rtsp://x", null, a);
+ AudioConfig a = parse("m=video 0 RTP/AVP 96\r\na=rtpmap:96 H264/90000\r\nm=audio 0 RTP/AVP 8\r\n",
+ "rtsp://x").audio;
assertEquals(AudioConfig.PCMA, a.codec);
assertEquals(8000, a.sampleRate);
- AudioConfig b = new AudioConfig();
- parse("m=audio 0 RTP/AVP 0\r\n", "rtsp://x", null, b);
+ AudioConfig b = parse("m=audio 0 RTP/AVP 0\r\n", "rtsp://x").audio;
assertEquals(AudioConfig.PCMU, b.codec);
}
@Test
public void l16BigEndianAndRtpmapOfVideoIsIgnored() {
- AudioConfig a = new AudioConfig();
- parse("m=video 0 RTP/AVP 100\r\na=rtpmap:100 H264/90000\r\n" +
- "m=audio 0 RTP/AVP 100\r\na=rtpmap:100 L16/16000\r\n", "rtsp://x", null, a);
+ AudioConfig a = parse("m=video 0 RTP/AVP 100\r\na=rtpmap:100 H264/90000\r\n" +
+ "m=audio 0 RTP/AVP 100\r\na=rtpmap:100 L16/16000\r\n", "rtsp://x").audio;
assertEquals(AudioConfig.PCM, a.codec);
assertEquals(16000, a.sampleRate);
assertTrue(a.bigEndian);
@@ -100,21 +95,73 @@ public void l16BigEndianAndRtpmapOfVideoIsIgnored() {
@Test
public void unsupportedAndMissingAudio() {
- AudioConfig a = new AudioConfig();
- parse("m=audio 0 RTP/AVP 111\r\na=rtpmap:111 opus/48000/2\r\n", "rtsp://x", null, a);
+ AudioConfig a = parse("m=audio 0 RTP/AVP 111\r\na=rtpmap:111 opus/48000/2\r\n", "rtsp://x").audio;
assertEquals(AudioConfig.NONE, a.codec);
- AudioConfig b = new AudioConfig();
- parse("m=video 0 RTP/AVP 96\r\n", "rtsp://x", null, b);
+ AudioConfig b = parse("m=video 0 RTP/AVP 96\r\n", "rtsp://x").audio;
assertEquals(AudioConfig.NONE, b.codec);
assertEquals(-1, b.pt);
}
@Test
- public void absoluteControlAndNullOutputs() {
- String[] urls = parse("m=video 0 RTP/AVP 96\r\na=control:rtsp://other/v\r\n",
- "rtsp://cam/s", null, null);
- assertEquals("rtsp://other/v", urls[0]);
- assertNull(null);
+ public void absoluteControl() {
+ Sdp sdp = parse("m=video 0 RTP/AVP 96\r\na=control:rtsp://other/v\r\n", "rtsp://cam/s");
+ assertEquals("rtsp://other/v", sdp.videoUrl);
+ }
+
+ @Test
+ public void sessionControlStarAndContentBase() {
+ // #19: a session-level "*" means the base URL itself
+ Sdp sdp = parse("a=control:*\r\nm=video 0 RTP/AVP 96\r\na=control:trackID=0\r\n",
+ "rtsp://cam/stream=0/");
+ assertEquals("rtsp://cam/stream=0/trackID=0", sdp.videoUrl);
+ }
+
+ @Test
+ public void rootRelativeControl() {
+ Sdp sdp = parse("m=video 0 RTP/AVP 96\r\na=control:/trackID=0\r\n",
+ "rtsp://cam:8554/live/main");
+ assertEquals("rtsp://cam:8554/trackID=0", sdp.videoUrl);
+ }
+
+ @Test
+ public void noAudioTrackMeansNoAudioUrl() {
+ // #18: no m=audio, so no audio SETUP
+ Sdp sdp = parse("m=video 0 RTP/AVP 96\r\n", "rtsp://cam/s");
+ assertNull(sdp.audioUrl);
+ assertEquals("rtsp://cam/s/trackID=0", sdp.videoUrl);
+ }
+
+ @Test
+ public void videoCodecComesFromRtpmap() {
+ // #9: H.265 on PT 96 and H.264 on PT 97
+ assertTrue(parse("m=video 0 RTP/AVP 96\r\na=rtpmap:96 H265/90000\r\n", "rtsp://x").videoH265);
+ Sdp h264 = parse("m=video 0 RTP/AVP 97\r\na=rtpmap:97 H264/90000\r\n", "rtsp://x");
+ assertEquals(97, h264.videoPt);
+ assertFalse(h264.videoH265);
+ // Without rtpmap the legacy 96/97 convention applies
+ assertTrue(parse("m=video 0 RTP/AVP 97\r\n", "rtsp://x").videoH265);
+ assertFalse(parse("m=video 0 RTP/AVP 96\r\n", "rtsp://x").videoH265);
+ }
+
+ @Test
+ public void firstSupportedVideoPayloadIsChosen() {
+ Sdp sdp = parse("m=video 0 RTP/AVP 26 98 99\r\na=rtpmap:26 JPEG/90000\r\n" +
+ "a=rtpmap:98 VP8/90000\r\na=rtpmap:99 H265/90000\r\n", "rtsp://x");
+ assertEquals(99, sdp.videoPt);
+ assertTrue(sdp.videoH265);
+ assertEquals(-1, parse("m=video 0 RTP/AVP 26\r\na=rtpmap:26 JPEG/90000\r\n",
+ "rtsp://x").videoPt);
+ }
+
+ @Test
+ public void payloadAndControlComeFromTheSameVideoSection() {
+ Sdp sdp = parse("m=video 0 RTP/AVP 26\r\na=rtpmap:26 JPEG/90000\r\na=control:mjpeg\r\n" +
+ "m=video 0 RTP/AVP 96\r\na=rtpmap:96 H264/90000\r\na=control:h264\r\n" +
+ "m=video 0 RTP/AVP 97\r\na=rtpmap:97 H265/90000\r\na=control:h265\r\n",
+ "rtsp://cam/s");
+ assertEquals(96, sdp.videoPt);
+ assertFalse(sdp.videoH265);
+ assertEquals("rtsp://cam/s/h264", sdp.videoUrl);
}
}