From 60604079f06c0efba13df9cb33c920e3acb13a69 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 10:33:36 +0000 Subject: [PATCH 1/3] ci(basic): fill Telegram fields, publish only from basic and v2 - Define GIT_HASH, GIT_BRANCH, TAG_NAME and TIME before they are used, so the Telegram caption is no longer empty (fixes #39). - Same branch policy as v2: only basic and v2 update the 'latest' release and post to Telegram; other branches upload a workflow artifact. A '/' in a branch name is replaced in the artifact name. --- .github/workflows/build.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f80d6f37..bd0ed263 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ jobs: basic) echo "name=decoder" ;; v2) echo "name=decoder-v2" ;; v3) echo "name=decoder-v3" ;; - *) echo "name=decoder-${{ github.ref_name }}" ;; + *) echo "name=decoder-$(echo '${{ github.ref_name }}' | tr '/' '-')" ;; esac >> $GITHUB_OUTPUT - name: Set up JDK 17 @@ -39,7 +39,19 @@ jobs: ./gradlew assembleRelease mv app/build/outputs/apk/release/app-release.apk ${{ steps.vars.outputs.name }}.apk + # Working branches: keep the build as a workflow artifact only + - name: Upload artifact + if: github.ref_name != 'basic' && github.ref_name != 'v2' + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.vars.outputs.name }} + path: | + ${{ steps.vars.outputs.name }}.aab + ${{ steps.vars.outputs.name }}.apk + + # Release branches: publish to the "latest" release and Telegram - name: Upload + if: github.ref_name == 'basic' || github.ref_name == 'v2' uses: softprops/action-gh-release@v2 with: tag_name: latest @@ -48,7 +60,12 @@ jobs: ${{ steps.vars.outputs.name }}.apk - name: Telegram + if: github.ref_name == 'basic' || github.ref_name == 'v2' run: | + GIT_HASH=$(git rev-parse --short HEAD) + GIT_BRANCH="${{ github.ref_name }}" + TAG_NAME="${{ github.ref_type == 'tag' && github.ref_name || '' }}" + TIME=$(date -u '+%Y-%m-%d %H:%M:%S UTC') TG_MSG="Commit: ${GIT_HASH}\nBranch: ${GIT_BRANCH}\nTag: ${TAG_NAME}\nTime: ${TIME}\n\n" TG_ICON="\xE2\x9C\x85 GitHub Actions" TG_HEADER=$(echo -e ${TG_MSG}${TG_ICON}) From f99f08b506e9ea438c90c12dc51e963c887f4fd8 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 10:33:36 +0000 Subject: [PATCH 2/3] fix(basic): robust RTSP handshake, EOF handling and thread lifecycle - tcp_stream(): every read is checked for end of stream and throws IOException, so a closed connection triggers a reconnect instead of an endless loop or a NegativeArraySizeException (fixes #36). - The handshake reads responses byte by byte from the same buffered stream that tcp_stream() uses, honours Content-Length for the SDP and checks status codes, so no RTP bytes are lost (fixes #37). - Worker threads exit on a generation counter instead of a shared boolean; flags are volatile and onPause closes the socket, so a quick pause/resume no longer leaves duplicate threads (fixes #38). - Track URLs come from a=control (including '*' and Content-Base), the video codec from a=rtpmap (legacy 96/97 without rtpmap), audio is only set up when present and only L16 is played, with the rate from the SDP. A failed audio SETUP is not fatal. The unused UDP code with fixed ports is removed, and credentials are no longer sent in the request line (fixes #40). - Odd-length L16 payloads no longer throw. Version 1.07.1. --- app/build.gradle | 2 +- .../java/com/openipc/decoder/Decoder.java | 417 ++++++++++++------ 2 files changed, 284 insertions(+), 135 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 13d28a3b..1e3b6270 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -19,7 +19,7 @@ android { minSdk 21 targetSdk 36 versionCode 7 - versionName "1.07" + versionName "1.07.1" } signingConfigs { diff --git a/app/src/main/java/com/openipc/decoder/Decoder.java b/app/src/main/java/com/openipc/decoder/Decoder.java index 7d915563..c9ec9b9c 100755 --- a/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/app/src/main/java/com/openipc/decoder/Decoder.java @@ -48,16 +48,17 @@ import android.widget.PopupWindow; import android.widget.TextView; -import java.io.BufferedReader; +import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; -import java.io.InputStreamReader; import java.io.OutputStream; -import java.net.DatagramPacket; -import java.net.DatagramSocket; import java.net.InetSocketAddress; import java.net.Socket; import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; @@ -74,16 +75,22 @@ public class Decoder extends Activity { private AudioTrack audioTrack; private TextView mConnect; - private boolean codecH265; + private volatile boolean codecH265; private boolean lastCodec; - private boolean listener; - private boolean activeStream; + private volatile boolean activeStream; + // Incremented on every start/stop; worker threads exit when it no longer matches + private volatile int listenerGen; + private volatile Socket mSocket; private int lastWidth; private int lastHeight; - private long lastFrame; + private volatile long lastFrame; - private String mHost; - private boolean mType; + private volatile String mHost; + + // Stream parameters taken from the SDP of the current session + private volatile int videoPt = -1; + private volatile int audioPt = -1; + private volatile int audioRate; @Override protected void onCreate(Bundle savedInstanceState) { @@ -132,6 +139,7 @@ private void saveSettings() { mConnect.setText(mHost); activeStream = false; + closeSocket(); } private void createMenu(View menu) { @@ -388,7 +396,7 @@ private void processAudio(Frame frame) { byte[] data = new byte[length]; System.arraycopy(frame.data(), header, data, 0, length); - for (int i = 0; i < length; i += 2) { + for (int i = 0; i + 1 < length; i += 2) { byte tmp = data[i]; data[i] = data[i + 1]; data[i + 1] = tmp; @@ -403,7 +411,8 @@ private void processAudio(Frame frame) { } private void createAudio(int length) { - int sample = length * 25; + // Rate from the SDP; legacy guess for servers that do not announce it + int sample = audioRate > 0 ? audioRate : length * 25; int format = AudioFormat.CHANNEL_OUT_MONO; Log.d(TAG, "Create audio decoder (" + sample + "hz)"); @@ -508,13 +517,189 @@ private void closeDecoder() { } } - private void rtsp_connect() throws Exception { + private void closeSocket() { + Socket s = mSocket; + if (s != null) { + try { + s.close(); + } catch (IOException ignored) { + } + } + } + + private static final class Response { + int code; + final Map headers = new HashMap<>(); + String body = ""; + } + + /** Read one CRLF-terminated line byte by byte, so no stream data is buffered away. */ + private static String readLine(InputStream in) throws IOException { + StringBuilder sb = new StringBuilder(); + int c; + while ((c = in.read()) != '\n') { + if (c < 0) throw new IOException("Connection closed"); + if (c != '\r') { + if (sb.length() > 8192) throw new IOException("RTSP line too long"); + sb.append((char) c); + } + } + return sb.toString(); + } + + private static void readFully(InputStream in, byte[] data, int len) throws IOException { + int read = 0; + while (read < len) { + int n = in.read(data, read, len - read); + if (n < 0) throw new IOException("Connection closed"); + read += n; + } + } + + private Response readResponse(InputStream in) throws IOException { + Response r = new Response(); + String status = readLine(in); + Log.i(TAG, status); + String[] parts = status.split(" ", 3); + if (parts.length < 2 || !parts[0].startsWith("RTSP/")) { + throw new IOException("Bad RTSP response: " + status); + } + try { + r.code = Integer.parseInt(parts[1]); + } catch (NumberFormatException e) { + throw new IOException("Bad RTSP status: " + status); + } + + String line; + while (!(line = readLine(in)).isEmpty()) { + Log.i(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.headers.get("content-length"); + if (length != null) { + int len = Integer.parseInt(length); + if (len < 0 || len > 65536) throw new IOException("Bad Content-Length: " + len); + byte[] body = new byte[len]; + readFully(in, body, len); + r.body = new String(body, StandardCharsets.UTF_8); + } + return r; + } + + private Response request(InputStream in, OutputStream w, String method, String url, + int seq, String headers) throws IOException { + String req = method + " " + url + " RTSP/1.0\r\n" + + "CSeq: " + seq + "\r\n" + headers + "\r\n"; + w.write(req.getBytes(StandardCharsets.UTF_8)); + w.flush(); + return readResponse(in); + } + + private static String resolveControl(String base, String control) { + if (control == null || control.equals("*")) return base; + if (control.startsWith("rtsp://") || control.startsWith("rtsps://")) return control; + return base.endsWith("/") ? base + control : base + "/" + control; + } + + /** Parse the SDP: returns { videoUrl, audioUrl or null } and sets the stream parameters. */ + private String[] parseSdp(String sdp, String base) { + String[] urls = { null, null }; + String[] controls = { null, null }; + String session = null; + int media = -1; // -1 session level, 0 video, 1 audio, 2 other + int vPt = -1; + int aPt = -1; + String vCodec = null; + String aCodec = null; + int aRate = 0; + boolean hasAudio = false; + + for (String line : sdp.split("\r?\n")) { + if (line.startsWith("m=")) { + String[] p = line.split("\\s+"); + int pt = -1; + try { + if (p.length >= 4) pt = Integer.parseInt(p[3]); + } catch (NumberFormatException ignored) { + } + if (line.startsWith("m=video")) { + media = 0; + vPt = pt; + } else if (line.startsWith("m=audio")) { + media = 1; + aPt = pt; + hasAudio = true; + } else { + media = 2; + } + } else if (line.startsWith("a=control:")) { + String ctrl = line.substring(10).trim(); + if (media == -1) session = ctrl; + else if (media < 2) controls[media] = ctrl; + } else if (line.startsWith("a=rtpmap:") && (media == 0 || media == 1)) { + // a=rtpmap: /[/] + String[] p = line.substring(9).trim().split("[\\s/]+"); + if (p.length < 2) continue; + if (media == 0 && p[0].equals(String.valueOf(vPt))) { + vCodec = p[1].toUpperCase(Locale.ROOT); + } else if (media == 1 && p[0].equals(String.valueOf(aPt))) { + aCodec = p[1].toUpperCase(Locale.ROOT); + try { + if (p.length > 2) aRate = Integer.parseInt(p[2]); + } catch (NumberFormatException ignored) { + } + } + } + } + + String sessionBase = resolveControl(base, session); + urls[0] = resolveControl(sessionBase, controls[0] != null ? controls[0] : "trackID=0"); + if (hasAudio) { + urls[1] = resolveControl(sessionBase, controls[1] != null ? controls[1] : "trackID=1"); + } + + // Video codec from rtpmap; servers without rtpmap keep the legacy 96/97 convention + videoPt = vPt; + if (vCodec != null) { + codecH265 = vCodec.equals("H265") || vCodec.equals("HEVC"); + } else { + codecH265 = vPt == 97; + } + + // Only 16-bit PCM (L16) is played; without rtpmap keep the legacy PT 100 + boolean pcm = aCodec != null ? aCodec.equals("L16") : aPt == 100; + audioPt = pcm ? aPt : -1; + audioRate = pcm && aRate > 0 && aRate <= 192000 ? aRate : 0; + + Log.i(TAG, "SDP: video pt " + vPt + " " + (codecH265 ? "H265" : "H264") + + ", audio pt " + aPt + " " + (aCodec != null ? aCodec : "?") + + (pcm ? "" : " (not played)")); + return urls; + } + + private void rtsp_connect(int gen) throws Exception { Uri uri = Uri.parse(mHost); + String host = uri.getHost(); + if (host == null || host.isEmpty()) throw new IOException("No host in URL"); + int port = uri.getPort(); + + // Request URL without user:password, which goes into the Authorization header + String path = uri.getEncodedPath(); + String query = uri.getEncodedQuery(); + String url = "rtsp://" + host + (port >= 0 ? ":" + port : "") + + (path != null ? path : "") + (query != null ? "?" + query : ""); + try (Socket s = new Socket()) { - int port = Uri.parse(mHost).getPort(); - s.connect(new InetSocketAddress(uri.getHost(), port < 0 ? 554 : port), 1000); - s.setSoTimeout(1000); - BufferedReader r = new BufferedReader(new InputStreamReader(s.getInputStream())); + mSocket = s; + if (gen != listenerGen) return; + s.connect(new InetSocketAddress(host, port < 0 ? 554 : port), 3000); + s.setSoTimeout(3000); + InputStream in = new BufferedInputStream(s.getInputStream(), 65536); OutputStream w = s.getOutputStream(); Log.d(TAG, "Start rtsp connection"); @@ -522,134 +707,100 @@ private void rtsp_connect() throws Exception { String user = uri.getUserInfo(); String auth = ""; if (user != null) { - auth = "Authorization: Basic " + - Base64.encodeToString(user.getBytes(), Base64.NO_WRAP) + "\r\n"; + auth = "Authorization: Basic " + Base64.encodeToString( + user.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP) + "\r\n"; } int seq = 1; - String desc = "DESCRIBE " + mHost + " RTSP/1.0\r\n" + - "CSeq: " + seq + "\r\n" + auth + "Accept: application/sdp\r\n\r\n"; - w.write(desc.getBytes()); - w.flush(); - - String line; - while ((line = r.readLine()) != null && !line.isEmpty()) { - Log.i(TAG, line); - } - - seq++; - String type = mType ? "RTP/AVP/UDP;unicast;client_port=5000" - : "RTP/AVP/TCP;unicast;interleaved=0-1"; - String video = "SETUP " + mHost + "/trackID=0 RTSP/1.0\r\n" + - "CSeq: " + seq + "\r\n" + auth + "Transport: " + type + "\r\n\r\n"; - w.write(video.getBytes()); - w.flush(); - - String session = null; - while ((line = r.readLine()) != null && !line.isEmpty()) { - if (line.startsWith("Session:")) { - session = line.replace("Session:", "").split(";")[0].trim(); + Response r = request(in, w, "DESCRIBE", url, seq++, + auth + "Accept: application/sdp\r\n"); + if (r.code / 100 != 2) throw new IOException("DESCRIBE failed: " + r.code); + String base = r.headers.get("content-base"); + String[] tracks = parseSdp(r.body, base != null ? base : url); + + r = request(in, w, "SETUP", tracks[0], seq++, + auth + "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n"); + if (r.code / 100 != 2) throw new IOException("SETUP video failed: " + r.code); + String session = r.headers.get("session"); + if (session == null) throw new IOException("No Session header"); + session = session.split(";")[0].trim(); + + if (tracks[1] != null) { + r = request(in, w, "SETUP", tracks[1], seq++, auth + + "Transport: RTP/AVP/TCP;unicast;interleaved=2-3\r\n" + + "Session: " + session + "\r\n"); + if (r.code / 100 != 2) { + Log.w(TAG, "SETUP audio failed: " + r.code + ", continuing without audio"); + audioPt = -1; } - Log.i(TAG, line); } - seq++; - type = mType ? "RTP/AVP/UDP;unicast;client_port=5002" - : "RTP/AVP/TCP;unicast;interleaved=2-3"; - String audio = "SETUP " + mHost + "/trackID=1 RTSP/1.0\r\n" + - "CSeq: " + seq + "\r\n" + auth + "Transport: " + type + "\r\n" + - "Session: " + session + "\r\n\r\n"; - w.write(audio.getBytes()); - w.flush(); - - while ((line = r.readLine()) != null && !line.isEmpty()) { - Log.i(TAG, line); - } - - seq++; - String play = "PLAY " + mHost + " RTSP/1.0\r\n" + - "CSeq: " + seq + "\r\n" + auth + "Session: " + session + "\r\n\r\n"; - w.write(play.getBytes()); - w.flush(); - - while ((line = r.readLine()) != null && !line.isEmpty()) { - Log.i(TAG, line); - } + r = request(in, w, "PLAY", url, seq, auth + "Session: " + session + "\r\n"); + if (r.code / 100 != 2) throw new IOException("PLAY failed: " + r.code); + lastFrame = SystemClock.elapsedRealtime(); activeStream = true; - if (mType) { - try (DatagramSocket d = new DatagramSocket(5000)) { - udp_stream(d); - } - } else { - tcp_stream(s.getInputStream()); + tcp_stream(in, gen); + } finally { + // A thread from an older generation must not clobber the state of a newer one + if (gen == listenerGen) { + mSocket = null; + activeStream = false; } } } - private void udp_stream(DatagramSocket d) throws IOException { - while (activeStream) { - byte[] data = new byte[2048]; - DatagramPacket packet = new DatagramPacket(data, data.length); - d.receive(packet); - processPacket(new Frame(packet.getData(), packet.getLength())); - } - } - - private void tcp_stream(InputStream input) throws IOException { - while (activeStream) { - if (input.read() != 0x24) { + private void tcp_stream(InputStream input, int gen) throws IOException { + while (activeStream && gen == listenerGen) { + int b = input.read(); + if (b < 0) throw new IOException("Connection closed"); + if (b != 0x24) { continue; } int channel = input.read(); - int len = (input.read() << 8) | input.read(); + int hi = input.read(); + int lo = input.read(); + if ((channel | hi | lo) < 0) throw new IOException("Connection closed"); + int len = (hi << 8) | lo; byte[] data = new byte[len]; + readFully(input, data, len); - int read = 0; - while (read < len) { - read += input.read(data, read, len - read); - } - - if (channel == 0 || channel == 2) { + if ((channel == 0 || channel == 2) && len > 13) { processPacket(new Frame(data, len)); } } } + private int lastUnknownPayload = -1; + private void processPacket(Frame frame) { byte[] data = frame.data(); int payload = (data[1] & 0x7F); - if (payload == 100) { - processAudio(frame); - return; - } else if (payload == 99) { - //Log.w(TAG, "AAC codec not supported"); - return; - } else if (payload == 98) { - //Log.w(TAG, "Opus codec not supported"); - return; - } else if (payload == 97 || payload == 96) { - codecH265 = payload == 97; + if (payload == videoPt) { + lastFrame = SystemClock.elapsedRealtime(); Frame output = buildFrame(frame); - if (output != null) { - try { - nalQueue.put(output); - } catch (InterruptedException e) { - Log.w(TAG, "Frame queue is full"); - } + if (output != null && !nalQueue.offer(output)) { + Log.w(TAG, "Frame queue is full"); } - + return; + } else if (payload == audioPt) { + processAudio(frame); return; } - Log.w(TAG, "Unknown rtp type: " + payload); + if (payload != lastUnknownPayload) { + lastUnknownPayload = payload; + Log.w(TAG, "Ignored rtp type: " + payload); + } } private void startListener() { Log.i(TAG, "Start network listener"); + final int gen = ++listenerGen; + new Thread(() -> { - while (listener) { + while (gen == listenerGen) { try { if (!activeStream) { runOnUiThread(() -> { @@ -657,63 +808,64 @@ private void startListener() { mSurface.setVisibility(View.VISIBLE); mConnect.setVisibility(View.VISIBLE); }); - rtsp_connect(); + rtsp_connect(gen); } } catch (Exception e) { Log.w(TAG, "Cannot connect rtsp: " + e.getMessage()); SystemClock.sleep(1000); } } - }).start(); + }, "rtsp-network").start(); new Thread(() -> { - while (listener) { - if (activeStream) { - if (SystemClock.elapsedRealtime() - lastFrame > 3000) { - Log.w(TAG, "Stream is inactive"); - activeStream = false; - } + while (gen == listenerGen) { + if (activeStream && SystemClock.elapsedRealtime() - lastFrame > 3000) { + Log.w(TAG, "Stream is inactive"); + activeStream = false; + closeSocket(); } SystemClock.sleep(1000); } - }).start(); + }, "rtsp-watchdog").start(); new Thread(() -> { - while (listener) { + while (gen == listenerGen) { try { Frame buffer = nalQueue.poll(5, TimeUnit.MILLISECONDS); - if (buffer != null) { + if (buffer != null && gen == listenerGen) { decodeFrame(buffer); } } catch (InterruptedException e) { Log.w(TAG, "Cannot decode video: " + e.getMessage()); } } - }).start(); + }, "rtsp-video").start(); new Thread(() -> { - while (listener) { + while (gen == listenerGen) { try { Frame buffer = pcmQueue.poll(5, TimeUnit.MILLISECONDS); - if (buffer != null) { + if (buffer != null && gen == listenerGen) { playAudio(buffer); } } catch (InterruptedException e) { Log.w(TAG, "Cannot decode audio: " + e.getMessage()); } } - }).start(); + }, "rtsp-audio").start(); } @Override protected void onPause() { super.onPause(); - if (listener) { - listener = false; - activeStream = false; - closeDecoder(); - closeAudio(); - } + // Old threads see the new generation and exit; closing the socket unblocks the reader + listenerGen++; + activeStream = false; + closeSocket(); + closeDecoder(); + closeAudio(); + nalQueue.clear(); + pcmQueue.clear(); } @Override @@ -721,10 +873,7 @@ protected void onResume() { super.onResume(); loadSettings(); - if (!listener) { - listener = true; - startListener(); - } + startListener(); mConnect.setText(mHost); mConnect.setVisibility(View.VISIBLE); From f62300e644b6d76f488f29c840d8e7b650dd1e96 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 10:38:41 +0000 Subject: [PATCH 3/3] fix(basic): address review findings - Skip interleaved RTP frames that arrive before an RTSP response. - Publish the socket and set activeStream only for the current generation, under one lock shared with onPause/startListener; a stale thread only clears its own socket. - Pick the first supported payload type from each m= line instead of only the first one listed. - Dispatch packets by interleaved channel: channel 0 is video and channel 2 is audio, even when both use the same payload type. - Resolve root-relative a=control values against scheme://host:port. - Recreate the AudioTrack when a reconnect announces another rate. --- .../java/com/openipc/decoder/Decoder.java | 168 ++++++++++++------ 1 file changed, 112 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/com/openipc/decoder/Decoder.java b/app/src/main/java/com/openipc/decoder/Decoder.java index c9ec9b9c..327e5a35 100755 --- a/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/app/src/main/java/com/openipc/decoder/Decoder.java @@ -56,7 +56,9 @@ import java.net.Socket; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.ArrayBlockingQueue; @@ -81,6 +83,8 @@ public class Decoder extends Activity { // Incremented on every start/stop; worker threads exit when it no longer matches private volatile int listenerGen; private volatile Socket mSocket; + // Guards mSocket and activeStream against a generation change from onPause/onResume + private final Object streamLock = new Object(); private int lastWidth; private int lastHeight; private volatile long lastFrame; @@ -383,6 +387,10 @@ private Frame buildFrame(Frame frame) { } private void playAudio(Frame data) { + // A reconnect may announce another rate: recreate the track + if (audioTrack != null && audioRate > 0 && audioTrack.getSampleRate() != audioRate) { + closeAudio(); + } if (audioTrack == null) { createAudio(data.length); } else { @@ -518,7 +526,10 @@ private void closeDecoder() { } private void closeSocket() { - Socket s = mSocket; + Socket s; + synchronized (streamLock) { + s = mSocket; + } if (s != null) { try { s.close(); @@ -558,6 +569,18 @@ private static void readFully(InputStream in, byte[] data, int len) throws IOExc private Response readResponse(InputStream in) throws IOException { Response r = new Response(); + // Some servers start sending interleaved RTP before the PLAY response: skip it + in.mark(1); + while (in.read() == 0x24) { + int channel = in.read(); + int hi = in.read(); + int lo = in.read(); + if ((channel | hi | lo) < 0) throw new IOException("Connection closed"); + int len = (hi << 8) | lo; + readFully(in, new byte[len], len); + in.mark(1); + } + in.reset(); String status = readLine(in); Log.i(TAG, status); String[] parts = status.split(" ", 3); @@ -603,6 +626,11 @@ private Response request(InputStream in, OutputStream w, String method, String u private static String resolveControl(String base, String control) { if (control == null || control.equals("*")) return base; if (control.startsWith("rtsp://") || control.startsWith("rtsps://")) return control; + if (control.startsWith("/")) { + // Root-relative: keep only scheme://host:port of the base + int hostEnd = base.indexOf('/', base.indexOf("://") + 3); + return (hostEnd < 0 ? base : base.substring(0, hostEnd)) + control; + } return base.endsWith("/") ? base + control : base + "/" + control; } @@ -612,30 +640,23 @@ private String[] parseSdp(String sdp, String base) { String[] controls = { null, null }; String session = null; int media = -1; // -1 session level, 0 video, 1 audio, 2 other - int vPt = -1; - int aPt = -1; - String vCodec = null; - String aCodec = null; - int aRate = 0; + // Payload types offered per media line, and their rtpmap: pt -> { NAME, rate } + List vPts = new ArrayList<>(); + List aPts = new ArrayList<>(); + Map vMap = new HashMap<>(); + Map aMap = new HashMap<>(); boolean hasAudio = false; for (String line : sdp.split("\r?\n")) { if (line.startsWith("m=")) { + media = line.startsWith("m=video") ? 0 : line.startsWith("m=audio") ? 1 : 2; + if (media == 1) hasAudio = true; String[] p = line.split("\\s+"); - int pt = -1; - try { - if (p.length >= 4) pt = Integer.parseInt(p[3]); - } catch (NumberFormatException ignored) { - } - if (line.startsWith("m=video")) { - media = 0; - vPt = pt; - } else if (line.startsWith("m=audio")) { - media = 1; - aPt = pt; - hasAudio = true; - } else { - media = 2; + for (int i = 3; i < p.length && media < 2; i++) { + try { + (media == 0 ? vPts : aPts).add(Integer.parseInt(p[i])); + } catch (NumberFormatException ignored) { + } } } else if (line.startsWith("a=control:")) { String ctrl = line.substring(10).trim(); @@ -645,15 +666,42 @@ private String[] parseSdp(String sdp, String base) { // a=rtpmap: /[/] String[] p = line.substring(9).trim().split("[\\s/]+"); if (p.length < 2) continue; - if (media == 0 && p[0].equals(String.valueOf(vPt))) { - vCodec = p[1].toUpperCase(Locale.ROOT); - } else if (media == 1 && p[0].equals(String.valueOf(aPt))) { - aCodec = p[1].toUpperCase(Locale.ROOT); - try { - if (p.length > 2) aRate = Integer.parseInt(p[2]); - } catch (NumberFormatException ignored) { - } + try { + (media == 0 ? vMap : aMap).put(Integer.parseInt(p[0]), new String[] { + p[1].toUpperCase(Locale.ROOT), p.length > 2 ? p[2] : "" }); + } catch (NumberFormatException ignored) { + } + } + } + + // Video: first offered H.264/H.265; without rtpmap keep the legacy 96/97 convention + int vPt = -1; + String vCodec = null; + for (int pt : vPts) { + String[] m = vMap.get(pt); + String name = m != null ? m[0] : pt == 97 ? "H265" : pt == 96 ? "H264" : null; + if ("H264".equals(name) || "H265".equals(name) || "HEVC".equals(name)) { + vPt = pt; + vCodec = name; + break; + } + } + if (vPt < 0 && !vPts.isEmpty()) vPt = vPts.get(0); + + // Audio: first offered L16, the only format played; without rtpmap the legacy PT 100 + int aPt = -1; + int aRate = 0; + String aCodec = null; + for (int pt : aPts) { + String[] m = aMap.get(pt); + if (m != null ? m[0].equals("L16") : pt == 100) { + aPt = pt; + aCodec = "L16"; + try { + if (m != null) aRate = Integer.parseInt(m[1]); + } catch (NumberFormatException ignored) { } + break; } } @@ -663,22 +711,13 @@ private String[] parseSdp(String sdp, String base) { urls[1] = resolveControl(sessionBase, controls[1] != null ? controls[1] : "trackID=1"); } - // Video codec from rtpmap; servers without rtpmap keep the legacy 96/97 convention videoPt = vPt; - if (vCodec != null) { - codecH265 = vCodec.equals("H265") || vCodec.equals("HEVC"); - } else { - codecH265 = vPt == 97; - } - - // Only 16-bit PCM (L16) is played; without rtpmap keep the legacy PT 100 - boolean pcm = aCodec != null ? aCodec.equals("L16") : aPt == 100; - audioPt = pcm ? aPt : -1; - audioRate = pcm && aRate > 0 && aRate <= 192000 ? aRate : 0; + codecH265 = vCodec != null ? !vCodec.equals("H264") : vPt == 97; + audioPt = aPt; + audioRate = aRate > 0 && aRate <= 192000 ? aRate : 0; Log.i(TAG, "SDP: video pt " + vPt + " " + (codecH265 ? "H265" : "H264") - + ", audio pt " + aPt + " " + (aCodec != null ? aCodec : "?") - + (pcm ? "" : " (not played)")); + + ", audio pt " + aPt + (aCodec != null ? " L16" : " (none played)")); return urls; } @@ -694,9 +733,12 @@ private void rtsp_connect(int gen) throws Exception { String url = "rtsp://" + host + (port >= 0 ? ":" + port : "") + (path != null ? path : "") + (query != null ? "?" + query : ""); - try (Socket s = new Socket()) { - mSocket = s; - if (gen != listenerGen) return; + Socket s = new Socket(); + try { + synchronized (streamLock) { + if (gen != listenerGen) return; + mSocket = s; + } s.connect(new InetSocketAddress(host, port < 0 ? 554 : port), 3000); s.setSoTimeout(3000); InputStream in = new BufferedInputStream(s.getInputStream(), 65536); @@ -738,14 +780,22 @@ private void rtsp_connect(int gen) throws Exception { r = request(in, w, "PLAY", url, seq, auth + "Session: " + session + "\r\n"); if (r.code / 100 != 2) throw new IOException("PLAY failed: " + r.code); - lastFrame = SystemClock.elapsedRealtime(); - activeStream = true; + synchronized (streamLock) { + // Paused meanwhile: do not mark an obsolete connection active + if (gen != listenerGen) return; + lastFrame = SystemClock.elapsedRealtime(); + activeStream = true; + } tcp_stream(in, gen); } finally { // A thread from an older generation must not clobber the state of a newer one - if (gen == listenerGen) { - mSocket = null; - activeStream = false; + synchronized (streamLock) { + if (mSocket == s) mSocket = null; + if (gen == listenerGen) activeStream = false; + } + try { + s.close(); + } catch (IOException ignored) { } } } @@ -767,24 +817,25 @@ private void tcp_stream(InputStream input, int gen) throws IOException { readFully(input, data, len); if ((channel == 0 || channel == 2) && len > 13) { - processPacket(new Frame(data, len)); + processPacket(new Frame(data, len), channel); } } } private int lastUnknownPayload = -1; - private void processPacket(Frame frame) { + private void processPacket(Frame frame, int channel) { byte[] data = frame.data(); int payload = (data[1] & 0x7F); - if (payload == videoPt) { + // Channel 0 carries the video track and channel 2 the audio track (see SETUP) + if (channel == 0 && payload == videoPt) { lastFrame = SystemClock.elapsedRealtime(); Frame output = buildFrame(frame); if (output != null && !nalQueue.offer(output)) { Log.w(TAG, "Frame queue is full"); } return; - } else if (payload == audioPt) { + } else if (channel == 2 && payload == audioPt) { processAudio(frame); return; } @@ -797,7 +848,10 @@ private void processPacket(Frame frame) { private void startListener() { Log.i(TAG, "Start network listener"); - final int gen = ++listenerGen; + final int gen; + synchronized (streamLock) { + gen = ++listenerGen; + } new Thread(() -> { while (gen == listenerGen) { @@ -859,8 +913,10 @@ private void startListener() { protected void onPause() { super.onPause(); // Old threads see the new generation and exit; closing the socket unblocks the reader - listenerGen++; - activeStream = false; + synchronized (streamLock) { + listenerGen++; + activeStream = false; + } closeSocket(); closeDecoder(); closeAudio();