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); } }