From 5e45caf054a384adaeb974cd4bb6c19c53e1f661 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 17:08:32 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20http://=20MJPEG=20slots=20=E2=80=94?= =?UTF-8?q?=20multipart/x-mixed-replace=20over=20HTTP,=20in=20the=20single?= =?UTF-8?q?=20view=20and=20in=20multi-view=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- .../java/com/openipc/decoder/HttpMjpeg.java | 300 ++++++++++++++++++ .../java/com/openipc/decoder/QuadCell.java | 13 + .../java/com/openipc/decoder/RtspClient.java | 11 + .../java/com/openipc/decoder/RtspSession.java | 93 +++++- .../com/openipc/decoder/HttpMjpegTest.java | 142 +++++++++ 5 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/com/openipc/decoder/HttpMjpeg.java create mode 100644 android/app/src/test/java/com/openipc/decoder/HttpMjpegTest.java diff --git a/android/app/src/main/java/com/openipc/decoder/HttpMjpeg.java b/android/app/src/main/java/com/openipc/decoder/HttpMjpeg.java new file mode 100644 index 00000000..7c4c4bf4 --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/HttpMjpeg.java @@ -0,0 +1,300 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * HttpMjpeg.java — MJPEG over HTTP: multipart/x-mixed-replace, one JPEG per part (#121) + * + */ + +package com.openipc.decoder; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * The HTTP side of an http:// slot such as Majestic's {@code http:///mjpeg}: the URL, + * the response head and the body, a {@code multipart/x-mixed-replace} stream whose parts are + * whole JPEG frames, chunked or not. The connection itself is RtspSession's, which also + * handles authentication (Basic, Digest) and reconnects. Pure Java, no Android types. + */ +final class HttpMjpeg { + /** Larger parts are not JPEG frames of a camera: the stream is given up. */ + static final int MAX_PART = 8 * 1024 * 1024; + private static final int MAX_LINE = 8192; + + private HttpMjpeg() {} + + static boolean matches(String url) { + return url != null && url.trim().toLowerCase(Locale.ROOT).startsWith("http://"); + } + + static final class Url { + String host; + int port = 80; + /** Path and query, "/" at least. */ + String path = "/"; + /** Decoded "user:password", or null. */ + String userInfo; + + static Url parse(String url) throws IOException { + if (!matches(url)) throw new IOException("Not an http:// URL"); + String rest = url.trim().substring("http://".length()); + int slash = rest.indexOf('/'); + String authority = slash >= 0 ? rest.substring(0, slash) : rest; + Url u = new Url(); + if (slash >= 0) u.path = rest.substring(slash); + int at = authority.lastIndexOf('@'); + if (at >= 0) { + u.userInfo = RtspSession.Url.percentDecode(authority.substring(0, at)); + authority = authority.substring(at + 1); + } + String host = authority; + int colon = authority.lastIndexOf(':'); + if (authority.startsWith("[")) { + int close = authority.indexOf(']'); + if (close < 0) throw new IOException("Bad host in URL"); + host = authority.substring(1, close); + colon = authority.indexOf(':', close); + } else if (colon >= 0) { + host = authority.substring(0, colon); + } + if (colon >= 0) { + try { + u.port = Integer.parseInt(authority.substring(colon + 1)); + } catch (NumberFormatException e) { + throw new IOException("Bad port in URL"); + } + if (u.port <= 0 || u.port > 65535) throw new IOException("Bad port in URL"); + } + if (host.isEmpty()) throw new IOException("No host in URL"); + u.host = host; + return u; + } + } + + /** Status and the headers that matter here. */ + static final class Response { + int status; + String contentType; + boolean chunked; + final List challenges = new ArrayList<>(); + + /** The multipart boundary, without the leading "--", or null when not multipart. */ + String boundary() { + if (contentType == null) return null; + String lower = contentType.toLowerCase(Locale.ROOT); + if (!lower.startsWith("multipart/")) return null; + int i = lower.indexOf("boundary="); + if (i < 0) return null; + String b = contentType.substring(i + "boundary=".length()).trim(); + int semi = b.indexOf(';'); + if (semi >= 0) b = b.substring(0, semi).trim(); + if (b.length() >= 2 && b.startsWith("\"") && b.endsWith("\"")) { + b = b.substring(1, b.length() - 1); + } + // Some servers put the dashes into the parameter too + if (b.startsWith("--")) b = b.substring(2); + return b.isEmpty() ? null : b; + } + } + + /** The status line and headers, up to the empty line. */ + static Response readResponse(InputStream in) throws IOException { + String line = readLine(in); + if (line == null) throw new IOException("No HTTP response"); + String[] parts = line.split(" ", 3); + if (parts.length < 2 || !parts[0].startsWith("HTTP/")) { + throw new IOException("Not an HTTP response"); + } + Response r = new Response(); + try { + r.status = Integer.parseInt(parts[1]); + } catch (NumberFormatException e) { + throw new IOException("Bad HTTP status"); + } + while ((line = readLine(in)) != null && !line.isEmpty()) { + int colon = line.indexOf(':'); + if (colon <= 0) continue; + String name = line.substring(0, colon).trim().toLowerCase(Locale.ROOT); + String value = line.substring(colon + 1).trim(); + if (name.equals("content-type")) r.contentType = value; + else if (name.equals("transfer-encoding")) { + r.chunked = value.toLowerCase(Locale.ROOT).contains("chunked"); + } else if (name.equals("www-authenticate")) r.challenges.add(value); + } + return r; + } + + interface FrameSink { + /** One JPEG frame; false to stop reading. */ + boolean onFrame(byte[] buf, int offset, int length); + } + + /** + * Reads parts until the stream ends, a part is malformed or the sink says stop. Each part + * goes to the sink whole: by its Content-Length, or else up to the next boundary. + */ + static void readParts(InputStream body, String boundary, FrameSink sink) throws IOException { + String delimiter = "--" + boundary; + byte[] buf = new byte[256 * 1024]; + String line; + // Up to the first delimiter (a preamble is allowed) + while ((line = readLine(body)) != null && !line.startsWith(delimiter)) { + // skip + } + while (line != null) { + if (line.startsWith(delimiter + "--")) return; // the closing delimiter + int length = -1; + while ((line = readLine(body)) != null && !line.isEmpty()) { + int colon = line.indexOf(':'); + if (colon > 0 && line.substring(0, colon).trim() + .equalsIgnoreCase("content-length")) { + try { + length = Integer.parseInt(line.substring(colon + 1).trim()); + } catch (NumberFormatException e) { + length = -1; + } + } + } + if (line == null) return; + if (length > MAX_PART) throw new IOException("MJPEG part of " + length + " bytes"); + int n; + if (length >= 0) { + if (buf.length < length) buf = new byte[length]; + readFully(body, buf, length); + n = length; + // The rest of the line after the data, then the next delimiter + while ((line = readLine(body)) != null && !line.startsWith(delimiter)) { + // skip + } + } else { + // No length: the data runs up to CRLF + delimiter + ByteArrayOutputStream part = new ByteArrayOutputStream(); + byte[] end = ("\r\n" + delimiter).getBytes(StandardCharsets.US_ASCII); + if (!readUntil(body, end, part)) return; + n = part.size(); + if (n > MAX_PART) throw new IOException("MJPEG part of " + n + " bytes"); + if (buf.length < n) buf = new byte[n]; + System.arraycopy(part.toByteArray(), 0, buf, 0, n); + // What follows the delimiter on its line: "--" closes the stream + String tail = readLine(body); + line = tail == null ? null : delimiter + tail; + } + if (n > 0 && !sink.onFrame(buf, 0, n)) return; + } + } + + /** Copies bytes into {@code out} until {@code end} has been read (not copied). */ + private static boolean readUntil(InputStream in, byte[] end, ByteArrayOutputStream out) + throws IOException { + int matched = 0; + int b; + while ((b = in.read()) >= 0) { + if (b == (end[matched] & 0xFF)) { + matched++; + if (matched == end.length) return true; + continue; + } + if (matched > 0) { + // Not the delimiter after all: what matched so far is data + out.write(end, 0, matched); + matched = b == (end[0] & 0xFF) ? 1 : 0; + if (matched == 0) out.write(b); + } else { + out.write(b); + } + if (out.size() > MAX_PART) throw new IOException("MJPEG part too large"); + } + return false; + } + + private static void readFully(InputStream in, byte[] buf, int length) throws IOException { + int off = 0; + while (off < length) { + int r = in.read(buf, off, length - off); + if (r < 0) throw new IOException("Stream ended inside a part"); + off += r; + } + } + + /** One CRLF (or LF) terminated ASCII line without the terminator; null at the end. */ + static String readLine(InputStream in) throws IOException { + StringBuilder sb = new StringBuilder(); + int b; + while ((b = in.read()) >= 0) { + if (b == '\n') { + int len = sb.length(); + if (len > 0 && sb.charAt(len - 1) == '\r') sb.setLength(len - 1); + return sb.toString(); + } + if (sb.length() >= MAX_LINE) throw new IOException("HTTP line too long"); + sb.append((char) b); + } + return sb.length() > 0 ? sb.toString() : null; + } + + /** The body of a chunked response as a plain stream (RFC 9112 7.1). */ + static final class Chunked extends InputStream { + private final InputStream in; + private int left; + private boolean done; + + Chunked(InputStream in) { + this.in = in; + } + + private boolean next() throws IOException { + if (done) return false; + if (left > 0) return true; + String line = readLine(in); + // The CRLF that ends the previous chunk's data + if (line != null && line.isEmpty()) line = readLine(in); + if (line == null) { + done = true; + return false; + } + int semi = line.indexOf(';'); + String hex = (semi >= 0 ? line.substring(0, semi) : line).trim(); + try { + left = Integer.parseInt(hex, 16); + } catch (NumberFormatException e) { + throw new IOException("Bad chunk size"); + } + if (left < 0) throw new IOException("Bad chunk size"); + if (left == 0) { + done = true; + return false; + } + return true; + } + + @Override + public int read() throws IOException { + if (!next()) return -1; + int b = in.read(); + if (b < 0) { + done = true; + return -1; + } + left--; + return b; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (len == 0) return 0; + if (!next()) return -1; + int r = in.read(b, off, Math.min(len, left)); + if (r < 0) { + done = true; + return -1; + } + left -= r; + return r; + } + } +} 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 5c596444..b58b37dd 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -549,6 +549,19 @@ public void onSdp(Sdp sdp) { if (sink != null) sink.configure(sdp.audio, audioRoute); } + @Override + public boolean onJpegFrame(byte[] buf, int offset, int length) { + // An http:// MJPEG slot (#121): the frame is whole already, no RTP to assemble + if (!jpeg || length < 4) return false; + videoPackets++; + framesIn++; + Frame frame = cellFramePool.obtain(length); + System.arraycopy(buf, offset, frame.data(), 0, length); + frame.setLength(length); + jpegSink.onNal(frame); + return true; + } + @Override public boolean onPacket(int channel, byte[] buf, int offset, int length) { if (length < 12) return false; 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 6b45af0d..2124f94c 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspClient.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspClient.java @@ -137,6 +137,17 @@ public void onSdp(Sdp sdp) { private final NalAssembler.Sink nalSink; private int queueFullDrops; + @Override + public boolean onJpegFrame(byte[] buf, int offset, int length) { + // An http:// MJPEG slot (#121): the frame is whole already, no RTP to assemble + if (!videoJpeg || length < 4) return false; + Frame frame = framePool.obtain(length); + System.arraycopy(buf, offset, frame.data(), 0, length); + frame.setLength(length); + nalSink.onNal(frame); + return true; + } + @Override public boolean onPacket(int channel, byte[] buf, int offset, int length) { if (length < 12) return false; diff --git a/android/app/src/main/java/com/openipc/decoder/RtspSession.java b/android/app/src/main/java/com/openipc/decoder/RtspSession.java index c35743a7..277504ce 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspSession.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspSession.java @@ -65,6 +65,12 @@ default void onFailure(String message) {} * Returns true if a video packet was usable; only those feed the watchdog. */ boolean onPacket(int channel, byte[] buf, int offset, int length); + + /** + * One whole JPEG frame of an http:// MJPEG slot (#121), after onSdp with videoJpeg. + * Returns true if it was usable; only those feed the watchdog. + */ + default boolean onJpegFrame(byte[] buf, int offset, int length) { return false; } } private final String tag; @@ -286,6 +292,7 @@ private void watchdogLoop(int gen) { /** One session from connect to disconnect. Returns true if PLAY succeeded. */ private boolean connect(int gen) throws IOException { if (RtpListener.Url.matches(url)) return listen(gen, RtpListener.Url.parse(url)); + if (HttpMjpeg.matches(url)) return httpMjpeg(gen, HttpMjpeg.Url.parse(url)); Url u = Url.parse(url); Socket s = new Socket(); synchronized (lock) { @@ -497,6 +504,90 @@ private boolean connect(int gen) throws IOException { * forces one: 96 is H.264 and 97 H.265, and a switch between them on the camera is * followed. Returns true once listening, like a PLAY that succeeded. */ + /** + * An http:// slot (#121): GET the URL and read its multipart/x-mixed-replace body, one + * JPEG per part. Basic credentials go with the first request, Digest after a challenge. + * Returns true once the stream was playing, like a PLAY that succeeded. + */ + private boolean httpMjpeg(int gen, HttpMjpeg.Url u) throws IOException { + RtspAuth auth = new RtspAuth(u.userInfo); + for (int attempt = 0; attempt < 3; attempt++) { + 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.setSoTimeout(CONNECT_TIMEOUT_MS); + String host = (u.host.indexOf(':') >= 0 ? "[" + u.host + "]" : u.host) + + (u.port == 80 ? "" : ":" + u.port); + String request = "GET " + u.path + " HTTP/1.1\r\n" + + "Host: " + host + "\r\n" + + "User-Agent: " + userAgent + "\r\n" + + auth.header("GET", u.path) + + "Connection: close\r\n\r\n"; + OutputStream out = s.getOutputStream(); + out.write(request.getBytes(StandardCharsets.ISO_8859_1)); + out.flush(); + InputStream in = new BufferedInputStream(s.getInputStream(), 64 * 1024); + HttpMjpeg.Response r = HttpMjpeg.readResponse(in); + if (r.status == 401) { + if (auth.onUnauthorized(r.challenges)) continue; + throw new IOException("401 Unauthorized"); + } + if (r.status != 200) throw new IOException("HTTP " + r.status); + String boundary = r.boundary(); + if (boundary == null) { + throw new IOException("Not an MJPEG stream (" + r.contentType + ")"); + } + InputStream body = r.chunked ? new HttpMjpeg.Chunked(in) : in; + synchronized (lock) { + if (gen != generation) return false; + lastPacketMs = System.nanoTime() / 1_000_000; + streamBytes = 0; + bitrateMarkNs = System.nanoTime(); + active = true; + inUse = "HTTP"; + } + Log.i(TAG, tag + ": MJPEG over HTTP from " + u.host + ":" + u.port); + status(gen, "buffering"); + Sdp sdp = new Sdp(); + sdp.videoJpeg = true; + sdp.videoPt = Sdp.PT_JPEG; + callback.onSdp(sdp); + // The watchdog handles silence from here on + s.setSoTimeout(0); + final boolean[] first = {true}; + HttpMjpeg.readParts(body, boundary, (buf, offset, length) -> { + if (gen != generation) return false; + streamBytes += length; + if (callback.onJpegFrame(buf, offset, length)) { + lastPacketMs = System.nanoTime() / 1_000_000; + if (first[0]) { + first[0] = false; + status(gen, "connected"); + } + } + return true; + }); + return true; + } finally { + synchronized (lock) { + if (socket == s) { + socket = null; + inUse = null; + } + } + try { + s.close(); + } catch (IOException ignored) { + } + } + } + throw new IOException("401 Unauthorized"); + } + private boolean listen(int gen, RtpListener.Url u) throws IOException { RtpListener l = new RtpListener(tag, u); synchronized (lock) { @@ -1078,7 +1169,7 @@ private static int parsePort(String s, String url) throws IOException { throw new IOException("Invalid port in URL: " + redact(url)); } - private static String percentDecode(String s) { + static String percentDecode(String s) { if (s.indexOf('%') < 0) return s; ByteArrayOutputStream out = new ByteArrayOutputStream(); for (int i = 0; i < s.length(); i++) { diff --git a/android/app/src/test/java/com/openipc/decoder/HttpMjpegTest.java b/android/app/src/test/java/com/openipc/decoder/HttpMjpegTest.java new file mode 100644 index 00000000..7271d75b --- /dev/null +++ b/android/app/src/test/java/com/openipc/decoder/HttpMjpegTest.java @@ -0,0 +1,142 @@ +package com.openipc.decoder; + +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; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class HttpMjpegTest { + private static final byte[] JPEG1 = {(byte) 0xFF, (byte) 0xD8, 1, 2, 3, (byte) 0xFF, (byte) 0xD9}; + private static final byte[] JPEG2 = {(byte) 0xFF, (byte) 0xD8, 9, '\r', '\n', '-', (byte) 0xFF, + (byte) 0xD9}; + + private static byte[] ascii(String s) { + return s.getBytes(StandardCharsets.US_ASCII); + } + + private static byte[] concat(byte[]... parts) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (byte[] p : parts) out.write(p, 0, p.length); + return out.toByteArray(); + } + + private static byte[] part(String boundary, byte[] jpeg, boolean withLength) { + String head = "--" + boundary + "\r\nContent-Type: image/jpeg\r\n" + + (withLength ? "Content-Length: " + jpeg.length + "\r\n" : "") + "\r\n"; + return concat(ascii(head), jpeg, ascii("\r\n")); + } + + /** Each piece as one HTTP chunk, as Majestic sends it. */ + private static byte[] chunked(byte[]... pieces) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (byte[] p : pieces) { + byte[] size = ascii(Integer.toHexString(p.length) + "\r\n"); + out.write(size, 0, size.length); + out.write(p, 0, p.length); + out.write('\r'); + out.write('\n'); + } + byte[] end = ascii("0\r\n\r\n"); + out.write(end, 0, end.length); + return out.toByteArray(); + } + + private static List read(InputStream body, String boundary) throws IOException { + List frames = new ArrayList<>(); + HttpMjpeg.readParts(body, boundary, (buf, off, len) -> { + frames.add(Arrays.copyOfRange(buf, off, off + len)); + return true; + }); + return frames; + } + + @Test + public void urlWithCredentialsPortAndPath() throws IOException { + HttpMjpeg.Url u = HttpMjpeg.Url.parse("http://root:12%3A34@172.17.32.133:8080/mjpeg?x=1"); + assertEquals("172.17.32.133", u.host); + assertEquals(8080, u.port); + assertEquals("/mjpeg?x=1", u.path); + assertEquals("root:12:34", u.userInfo); + HttpMjpeg.Url plain = HttpMjpeg.Url.parse("http://cam"); + assertEquals(80, plain.port); + assertEquals("/", plain.path); + assertNull(plain.userInfo); + assertTrue(HttpMjpeg.matches(" HTTP://cam/mjpeg")); + assertFalse(HttpMjpeg.matches("rtsp://cam/stream=0")); + } + + @Test + public void responseHeadOfMajestic() throws IOException { + InputStream in = new ByteArrayInputStream(ascii("HTTP/1.1 200 OK\r\n" + + "Content-Type: multipart/x-mixed-replace; boundary=boundarydonotcross\r\n" + + "Transfer-Encoding: chunked\r\n\r\n")); + HttpMjpeg.Response r = HttpMjpeg.readResponse(in); + assertEquals(200, r.status); + assertTrue(r.chunked); + assertEquals("boundarydonotcross", r.boundary()); + } + + @Test + public void unauthorizedKeepsTheChallenge() throws IOException { + InputStream in = new ByteArrayInputStream(ascii("HTTP/1.1 401 Unauthorized\r\n" + + "WWW-Authenticate: Basic realm=\"Authentication\"\r\n\r\n")); + HttpMjpeg.Response r = HttpMjpeg.readResponse(in); + assertEquals(401, r.status); + assertEquals(1, r.challenges.size()); + assertNull(r.boundary()); + } + + @Test + public void quotedBoundaryWithDashes() { + HttpMjpeg.Response r = new HttpMjpeg.Response(); + r.contentType = "multipart/x-mixed-replace;boundary=\"--myboundary\""; + assertEquals("myboundary", r.boundary()); + r.contentType = "image/jpeg"; + assertNull(r.boundary()); + } + + @Test + public void chunkedPartsWithContentLength() throws IOException { + String b = "boundarydonotcross"; + byte[] body = chunked(part(b, JPEG1, true), part(b, JPEG2, true)); + List frames = read(new HttpMjpeg.Chunked(new ByteArrayInputStream(body)), b); + assertEquals(2, frames.size()); + assertArrayEquals(JPEG1, frames.get(0)); + assertArrayEquals(JPEG2, frames.get(1)); + } + + @Test + public void partsWithoutLengthRunToTheNextBoundary() throws IOException { + String b = "frame"; + byte[] body = concat(part(b, JPEG1, false), part(b, JPEG2, false), ascii("--frame--\r\n")); + List frames = read(new ByteArrayInputStream(body), b); + assertEquals(2, frames.size()); + assertArrayEquals(JPEG1, frames.get(0)); + // CR LF '-' inside the data is not taken for a delimiter + assertArrayEquals(JPEG2, frames.get(1)); + } + + @Test + public void aChunkSplitInsideAPartStillGivesWholeFrames() throws IOException { + String b = "b"; + byte[] all = concat(part(b, JPEG1, true), part(b, JPEG2, true)); + byte[] body = chunked(Arrays.copyOfRange(all, 0, 20), Arrays.copyOfRange(all, 20, 37), + Arrays.copyOfRange(all, 37, all.length)); + List frames = read(new HttpMjpeg.Chunked(new ByteArrayInputStream(body)), b); + assertEquals(2, frames.size()); + assertArrayEquals(JPEG1, frames.get(0)); + assertArrayEquals(JPEG2, frames.get(1)); + } +} From a983899ba7b419498a52d1615317c7d4ceff6033 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 17:09:59 +0000 Subject: [PATCH 2/5] fix: the HTTP frame goes to the cell's NAL sink (#121) Co-Authored-By: Claude --- android/app/src/main/java/com/openipc/decoder/QuadCell.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b58b37dd..9078ddc4 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -558,7 +558,7 @@ public boolean onJpegFrame(byte[] buf, int offset, int length) { Frame frame = cellFramePool.obtain(length); System.arraycopy(buf, offset, frame.data(), 0, length); frame.setLength(length); - jpegSink.onNal(frame); + nalSink.onNal(frame); return true; } From 952373362d0fe60f5f34f49c22ad7b6b9a4967ef Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 17:19:11 +0000 Subject: [PATCH 3/5] fix: the HTTP request takes the User-Agent line as it is (#121) Co-Authored-By: Claude --- android/app/src/main/java/com/openipc/decoder/RtspSession.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/openipc/decoder/RtspSession.java b/android/app/src/main/java/com/openipc/decoder/RtspSession.java index 277504ce..40bd0581 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspSession.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspSession.java @@ -524,7 +524,8 @@ private boolean httpMjpeg(int gen, HttpMjpeg.Url u) throws IOException { + (u.port == 80 ? "" : ":" + u.port); String request = "GET " + u.path + " HTTP/1.1\r\n" + "Host: " + host + "\r\n" - + "User-Agent: " + userAgent + "\r\n" + // userAgent is the whole header line already, CRLF included + + userAgent + auth.header("GET", u.path) + "Connection: close\r\n\r\n"; OutputStream out = s.getOutputStream(); From cac8f6bbd85db946c256a30305b8b67b57d6ed3a Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 17:28:10 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20Qodo=20on=20#150=20=E2=80=94=20no=20?= =?UTF-8?q?header=20injection=20through=20the=20URL=20path=20or=20credenti?= =?UTF-8?q?als,=20query/fragment=20URLs,=20quoted=20boundaries,=20LF-only?= =?UTF-8?q?=20and=20boundary-like=20data=20in=20lengthless=20parts,=20only?= =?UTF-8?q?=20JPEG=20parts=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- .../java/com/openipc/decoder/HttpMjpeg.java | 73 +++++++++++++++---- .../java/com/openipc/decoder/QuadCell.java | 2 + .../java/com/openipc/decoder/RtspClient.java | 2 + .../com/openipc/decoder/HttpMjpegTest.java | 47 ++++++++++++ 4 files changed, 108 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/HttpMjpeg.java b/android/app/src/main/java/com/openipc/decoder/HttpMjpeg.java index 7c4c4bf4..3647f6f8 100644 --- a/android/app/src/main/java/com/openipc/decoder/HttpMjpeg.java +++ b/android/app/src/main/java/com/openipc/decoder/HttpMjpeg.java @@ -43,13 +43,37 @@ static final class Url { static Url parse(String url) throws IOException { if (!matches(url)) throw new IOException("Not an http:// URL"); String rest = url.trim().substring("http://".length()); - int slash = rest.indexOf('/'); - String authority = slash >= 0 ? rest.substring(0, slash) : rest; + // The fragment is not sent (Qodo on #150) + int hash = rest.indexOf('#'); + if (hash >= 0) rest = rest.substring(0, hash); + // The authority ends at '/' or '?' (http://camera?stream=1) + int end = rest.length(); + for (int i = 0; i < rest.length(); i++) { + char ch = rest.charAt(i); + if (ch == '/' || ch == '?') { + end = i; + break; + } + } + String authority = rest.substring(0, end); Url u = new Url(); - if (slash >= 0) u.path = rest.substring(slash); + if (end < rest.length()) { + u.path = rest.charAt(end) == '?' ? "/" + rest.substring(end) : rest.substring(end); + } + // The path goes into the request line: no spaces or control characters (Qodo on #150) + for (int i = 0; i < u.path.length(); i++) { + char ch = u.path.charAt(i); + if (ch <= 0x20 || ch == 0x7F) throw new IOException("Bad character in URL path"); + } int at = authority.lastIndexOf('@'); if (at >= 0) { u.userInfo = RtspSession.Url.percentDecode(authority.substring(0, at)); + for (int i = 0; i < u.userInfo.length(); i++) { + char ch = u.userInfo.charAt(i); + if (ch < 0x20 || ch == 0x7F) { + throw new IOException("Bad character in URL credentials"); + } + } authority = authority.substring(at + 1); } String host = authority; @@ -91,10 +115,14 @@ String boundary() { int i = lower.indexOf("boundary="); if (i < 0) return null; String b = contentType.substring(i + "boundary=".length()).trim(); - int semi = b.indexOf(';'); - if (semi >= 0) b = b.substring(0, semi).trim(); - if (b.length() >= 2 && b.startsWith("\"") && b.endsWith("\"")) { - b = b.substring(1, b.length() - 1); + if (b.startsWith("\"")) { + // Quoted: up to the closing quote, a ';' inside belongs to it (Qodo on #150) + int close = b.indexOf('"', 1); + if (close < 0) return null; + b = b.substring(1, close); + } else { + int semi = b.indexOf(';'); + if (semi >= 0) b = b.substring(0, semi).trim(); } // Some servers put the dashes into the parameter too if (b.startsWith("--")) b = b.substring(2); @@ -172,17 +200,30 @@ static void readParts(InputStream body, String boundary, FrameSink sink) throws // skip } } else { - // No length: the data runs up to CRLF + delimiter + // No length: the data runs up to a line that is the delimiter, after LF or CR LF + // (Qodo on #150); "\n--boundary" inside the data with more on its line is data ByteArrayOutputStream part = new ByteArrayOutputStream(); - byte[] end = ("\r\n" + delimiter).getBytes(StandardCharsets.US_ASCII); - if (!readUntil(body, end, part)) return; - n = part.size(); - if (n > MAX_PART) throw new IOException("MJPEG part of " + n + " bytes"); + byte[] end = ("\n" + delimiter).getBytes(StandardCharsets.US_ASCII); + String tail; + while (true) { + if (!readUntil(body, end, part)) return; + tail = readLine(body); + if (tail == null) return; + String t = tail.trim(); + if (t.isEmpty() || t.equals("--")) break; + // Not a delimiter line: put back what was taken for one + byte[] back = ("\n" + delimiter + tail + "\n") + .getBytes(StandardCharsets.ISO_8859_1); + part.write(back, 0, back.length); + if (part.size() > MAX_PART) throw new IOException("MJPEG part too large"); + } + byte[] data = part.toByteArray(); + n = data.length; + if (n > 0 && data[n - 1] == '\r') n--; if (buf.length < n) buf = new byte[n]; - System.arraycopy(part.toByteArray(), 0, buf, 0, n); - // What follows the delimiter on its line: "--" closes the stream - String tail = readLine(body); - line = tail == null ? null : delimiter + tail; + System.arraycopy(data, 0, buf, 0, n); + // "--" after the delimiter closes the stream + line = delimiter + tail.trim(); } if (n > 0 && !sink.onFrame(buf, 0, n)) return; } 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 c4dbe96e..83204945 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -562,6 +562,8 @@ public void onSdp(Sdp sdp) { public boolean onJpegFrame(byte[] buf, int offset, int length) { // An http:// MJPEG slot (#121): the frame is whole already, no RTP to assemble if (!jpeg || length < 4) return false; + // A JPEG at all (SOI); anything else must not keep the stream "connected" (Qodo on #150) + if ((buf[offset] & 0xFF) != 0xFF || (buf[offset + 1] & 0xFF) != 0xD8) return false; videoPackets++; framesIn++; Frame frame = cellFramePool.obtain(length); 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 2124f94c..287e4a67 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspClient.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspClient.java @@ -141,6 +141,8 @@ public void onSdp(Sdp sdp) { public boolean onJpegFrame(byte[] buf, int offset, int length) { // An http:// MJPEG slot (#121): the frame is whole already, no RTP to assemble if (!videoJpeg || length < 4) return false; + // A JPEG at all (SOI); anything else must not keep the stream "connected" (Qodo on #150) + if ((buf[offset] & 0xFF) != 0xFF || (buf[offset + 1] & 0xFF) != 0xD8) return false; Frame frame = framePool.obtain(length); System.arraycopy(buf, offset, frame.data(), 0, length); frame.setLength(length); diff --git a/android/app/src/test/java/com/openipc/decoder/HttpMjpegTest.java b/android/app/src/test/java/com/openipc/decoder/HttpMjpegTest.java index 7271d75b..8f946576 100644 --- a/android/app/src/test/java/com/openipc/decoder/HttpMjpegTest.java +++ b/android/app/src/test/java/com/openipc/decoder/HttpMjpegTest.java @@ -139,4 +139,51 @@ public void aChunkSplitInsideAPartStillGivesWholeFrames() throws IOException { assertArrayEquals(JPEG1, frames.get(0)); assertArrayEquals(JPEG2, frames.get(1)); } + + @Test + public void urlQueryWithoutPathAndFragment() throws IOException { + HttpMjpeg.Url u = HttpMjpeg.Url.parse("http://cam?stream=1#preview"); + assertEquals("cam", u.host); + assertEquals("/?stream=1", u.path); + assertEquals("/mjpeg", HttpMjpeg.Url.parse("http://cam/mjpeg#x").path); + } + + @Test(expected = IOException.class) + public void aLineBreakInThePathIsRejected() throws IOException { + HttpMjpeg.Url.parse("http://cam/mjpeg\r\nX-Evil: 1"); + } + + @Test(expected = IOException.class) + public void aLineBreakInTheCredentialsIsRejected() throws IOException { + HttpMjpeg.Url.parse("http://ro%0D%0Aot:pw@cam/mjpeg"); + } + + @Test + public void quotedBoundaryWithASemicolon() { + HttpMjpeg.Response r = new HttpMjpeg.Response(); + r.contentType = "multipart/x-mixed-replace; boundary=\"a;b\"; charset=x"; + assertEquals("a;b", r.boundary()); + } + + @Test + public void lfOnlyPartsWithoutLength() throws IOException { + byte[] body = concat(ascii("--f\nContent-Type: image/jpeg\n\n"), JPEG1, + ascii("\n--f\nContent-Type: image/jpeg\n\n"), JPEG2, ascii("\n--f--\n")); + List frames = read(new ByteArrayInputStream(body), "f"); + assertEquals(2, frames.size()); + assertArrayEquals(JPEG1, frames.get(0)); + assertArrayEquals(JPEG2, frames.get(1)); + } + + @Test + public void boundaryTextInsideTheDataIsData() throws IOException { + // "\r\n--f" followed by more on that line is not a delimiter line + byte[] tricky = concat(new byte[] {(byte) 0xFF, (byte) 0xD8}, + ascii("\r\n--fX\r\nmore"), new byte[] {(byte) 0xFF, (byte) 0xD9}); + byte[] body = concat(part("f", tricky, false), part("f", JPEG1, false), ascii("--f--\r\n")); + List frames = read(new ByteArrayInputStream(body), "f"); + assertEquals(2, frames.size()); + assertArrayEquals(tricky, frames.get(0)); + assertArrayEquals(JPEG1, frames.get(1)); + } } From bc26e690460758666b2a6f0304128d099fe7c973 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Tue, 13 Oct 2026 17:29:12 +0000 Subject: [PATCH 5/5] fix: data that only looks like a delimiter line goes back byte for byte (#150) Co-Authored-By: Claude --- .../java/com/openipc/decoder/HttpMjpeg.java | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/HttpMjpeg.java b/android/app/src/main/java/com/openipc/decoder/HttpMjpeg.java index 3647f6f8..4835548b 100644 --- a/android/app/src/main/java/com/openipc/decoder/HttpMjpeg.java +++ b/android/app/src/main/java/com/openipc/decoder/HttpMjpeg.java @@ -207,13 +207,13 @@ static void readParts(InputStream body, String boundary, FrameSink sink) throws String tail; while (true) { if (!readUntil(body, end, part)) return; - tail = readLine(body); + // The rest of the line as it is, terminator included + tail = readRawLine(body); if (tail == null) return; String t = tail.trim(); if (t.isEmpty() || t.equals("--")) break; - // Not a delimiter line: put back what was taken for one - byte[] back = ("\n" + delimiter + tail + "\n") - .getBytes(StandardCharsets.ISO_8859_1); + // Not a delimiter line: put back exactly what was taken for one + byte[] back = ("\n" + delimiter + tail).getBytes(StandardCharsets.ISO_8859_1); part.write(back, 0, back.length); if (part.size() > MAX_PART) throw new IOException("MJPEG part too large"); } @@ -262,6 +262,18 @@ private static void readFully(InputStream in, byte[] buf, int length) throws IOE } } + /** Bytes up to and including LF, as ISO-8859-1; null at the end of the stream. */ + private static String readRawLine(InputStream in) throws IOException { + StringBuilder sb = new StringBuilder(); + int b; + while ((b = in.read()) >= 0) { + sb.append((char) b); + if (b == '\n') return sb.toString(); + if (sb.length() >= MAX_LINE) return sb.toString(); + } + return sb.length() > 0 ? sb.toString() : null; + } + /** One CRLF (or LF) terminated ASCII line without the terminator; null at the end. */ static String readLine(InputStream in) throws IOException { StringBuilder sb = new StringBuilder();