From 52d93ad5922f877d62f155c48042ebe5b1615178 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Wed, 14 Oct 2026 14:15:35 +0000 Subject: [PATCH 1/6] feat: debug page keeps the grid in single view, with short stream checks of the other slots (#100) In single view (and the U view) the page, the snapshot and stats.json (under 'slots') show the whole grid: the camera on screen framed, every other configured slot checked about every 45 s while the page is in use - an RTSP or HTTP MJPEG session without audio, ~3 s of packets counted (frames, key frames, bitrate; size for MJPEG), then closed. No decoder. At most two checks at a time, never two to one camera, never the camera on screen (XM cameras take one client), no udp:// slots; checks stop in multi-view, on pause, and 60 s after the last debug request. --- README.md | 1 + README.ru.md | 1 + README.zh-CN.md | 1 + .../java/com/openipc/decoder/DebugServer.java | 26 +- .../java/com/openipc/decoder/Decoder.java | 159 ++++++++- .../java/com/openipc/decoder/Snapshots.java | 16 +- .../java/com/openipc/decoder/StreamProbe.java | 318 ++++++++++++++++++ .../com/openipc/decoder/StreamProbes.java | 247 ++++++++++++++ .../com/openipc/decoder/StreamProbeTest.java | 189 +++++++++++ 9 files changed, 932 insertions(+), 26 deletions(-) create mode 100644 android/app/src/main/java/com/openipc/decoder/StreamProbe.java create mode 100644 android/app/src/main/java/com/openipc/decoder/StreamProbes.java create mode 100644 android/app/src/test/java/com/openipc/decoder/StreamProbeTest.java diff --git a/README.md b/README.md index 21cf5b8f..e8d710a2 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ While the app runs it serves its debug information over HTTP on port **8085**, f - `http://:8085/` — the snapshot fills the page, with each cell's camera, size, fps and losses (in red) drawn over it; the header shows the version, the device time and uptime. **Info On / Off** in the header hides the text over the cells (and the list below them on a narrow screen), leaving only the picture; the choice is remembered in the browser. A red note in the header says when the device's video decoder stopped responding (`decoder_stuck`); - `http://:8085/snapshot.jpg` (`?labels=0`: frames only) — a picture laid out like the screen, at the screen's resolution (at most 1920×1080), made from the frames the app shows right now; a cell without video is black with its status. Taken only when asked for; the page refreshes it every 3, 5 or 10 s while the tab is visible; - `http://:8085/stats.json` — the same as JSON, for scripts; +- In single view the page and the snapshot still show the whole grid (#100): the camera on screen is framed in blue, every other configured slot gets a short stream check (connect, a few seconds of packets, no decoding) about every 45 s while the page is open, and its cell says what was found, e.g. `Stream OK · H.265 · 25 fps · 3.1 Mbit/s · checked 12 s ago`, or the error. A slot of the camera on screen is never checked (some cameras take one client only), nor a `udp://` slot. `stats.json` has these cells under `slots`; - `http://:8085/log` — the last 500 lines of the app's log. Only clients on the local network are answered (loopback, 10/8, 172.16/12, 192.168/16, link-local, IPv6 fc00::/7); others get 403. Logins and passwords are never shown. diff --git a/README.ru.md b/README.ru.md index 9b52f265..13f1ae38 100644 --- a/README.ru.md +++ b/README.ru.md @@ -147,6 +147,7 @@ outgoing: - `http://<приставка>:8085/` — снимок на всю страницу, поверх каждой ячейки камера, размер, fps и потери (красным); в шапке версия, время приставки и аптайм. Переключатель **Info On / Off** в шапке убирает текст поверх ячеек (и список под ними на узком экране), остаётся только картинка; выбор запоминается в браузере. Красная пометка в шапке сообщает, что видеодекодер устройства перестал отвечать (`decoder_stuck`); - `http://<приставка>:8085/snapshot.jpg` (`?labels=0` — только кадры) — картинка с раскладкой экрана в разрешении экрана (не больше 1920×1080) из кадров, которые приложение показывает сейчас; ячейка без видео — чёрная с её статусом. Снимается только по запросу; страница обновляет её раз в 3, 5 или 10 с, пока вкладка видна; - `http://<приставка>:8085/stats.json` — то же в JSON, для скриптов; +- В одиночном виде страница и снимок всё равно показывают всю сетку (#100): камера на экране обведена синей рамкой, а каждый другой настроенный слот, пока страница открыта, примерно раз в 45 с получает короткую проверку потока (подключение, несколько секунд пакетов, без декодирования); в его ячейке — результат, например `Stream OK · H.265 · 25 fps · 3.1 Mbit/s · checked 12 s ago`, или ошибка. Слот с камерой, которая на экране, не проверяется (некоторые камеры принимают только одного клиента), как и слоты `udp://`. В `stats.json` эти ячейки — в поле `slots`; - `http://<приставка>:8085/log` — последние 500 строк лога приложения. Отвечает только клиентам из локальной сети (loopback, 10/8, 172.16/12, 192.168/16, link-local, IPv6 fc00::/7), остальным — 403. Логины и пароли не показываются. diff --git a/README.zh-CN.md b/README.zh-CN.md index fe88c83a..6a240c5c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -147,6 +147,7 @@ outgoing: - `http://<设备>:8085/` — 快照铺满页面,每个画面上叠加摄像机、分辨率、帧率和丢帧(红色);页头显示版本、设备时间和运行时长。页头的 **Info On / Off** 开关可隐藏画面上的文字(以及窄屏时下方的列表),只保留画面;选择会保存在浏览器中。设备视频解码器停止响应时,页头会出现红色提示(`decoder_stuck`); - `http://<设备>:8085/snapshot.jpg`(`?labels=0`:仅画面)— 按屏幕布局拼成的图片,分辨率与屏幕相同(最大 1920×1080),取自应用当前显示的画面;没有视频的画面为黑色并显示其状态。仅在请求时截取;页面在标签页可见时每 3、5 或 10 秒刷新一次; - `http://<设备>:8085/stats.json` — 相同内容的 JSON,便于脚本使用; +- 单画面模式下,页面和快照仍显示完整网格(#100):屏幕上的摄像机用蓝框标出;页面打开期间,其他每个已配置的槽位约每 45 秒进行一次简短的流检测(连接、接收几秒数据包、不解码),其画面显示检测结果,例如 `Stream OK · H.265 · 25 fps · 3.1 Mbit/s · checked 12 s ago`,或错误信息。与屏幕上相同的摄像机的槽位不检测(部分摄像机只接受一个客户端),`udp://` 槽位也不检测。`stats.json` 中这些画面位于 `slots` 字段; - `http://<设备>:8085/log` — 应用日志的最后 500 行。 仅响应本地网络中的客户端(loopback、10/8、172.16/12、192.168/16、链路本地地址、IPv6 fc00::/7),其他地址返回 403。不会显示用户名和密码。 diff --git a/android/app/src/main/java/com/openipc/decoder/DebugServer.java b/android/app/src/main/java/com/openipc/decoder/DebugServer.java index 924261d2..bb541879 100644 --- a/android/app/src/main/java/com/openipc/decoder/DebugServer.java +++ b/android/app/src/main/java/com/openipc/decoder/DebugServer.java @@ -399,7 +399,8 @@ private static void closeQuietly(Socket s) { * screen the snapshot takes the full width and the cells are listed below it (#103). It * fetches /stats.json every 1.5 s and the snapshot (without labels) every 3, 5 or 10 s, * both only while the tab is visible (#98). The Info switch hides the text over the cells - * and the list, leaving the header and the snapshot (#141). + * and the list, leaving the header and the snapshot (#141). In single view the grid stays, + * with the camera on screen framed and the stream checks of the other slots (#100). */ private static final String PAGE = "\n" + "'+esc(c.text)+'';\n" + + "function ok(c){return c.probe&&c.probe.state==='ok'}\n" + + "function info(c){if(!c.video)return ''+" + + "esc(c.text)+'';\n" + "var t=esc(c.text),r=/(\\d+)\\/(\\d+) fps/.exec(c.text||'');\n" + "if(r&&+r[1]<0.9*r[2])t=t.replace(r[0],''+r[0]+'');\n" + "if(c.lost_fps>0)t+=' \u00b7 lost '+num(c.lost_fps)" @@ -499,11 +504,13 @@ private static void closeQuietly(Socket s) { + "return t}\n" // One cell: slot, camera and transport on top; size, fps and losses below + "function cell(c){var top=''+esc(c.slot)+''+where(c)+'';\n" - + "if(!c.video)return '
'+esc(c.slot)+'" - + "'+esc(c.text)+'
'+top+'
';\n" - + "return '
'+top+'
" - + "'+info(c)+'
'}\n" + + " class=\"dim\">'+where(c)+(c.on_screen?' \u00b7 on screen':'')+''," + + "cls=c.on_screen?'cell on':'cell';\n" + + "if(!c.video)return '
" + + "'+esc(c.slot)+''+esc(c.text)+'
'+top+" + + "'
';\n" + + "return '
'+top+'
'+info(c)+'
'}\n" + "function row(c){return ''+esc(c.slot)+'" + "'+where(c)+'
'+info(c)+''}\n" + "function render(s){" @@ -518,8 +525,11 @@ private static void closeQuietly(Socket s) { + ":ui?'UI '+s.ui_thread:none?(s.mode||'no data'):'';\n" + "$('dot').className='dot '+(stuck?'bad':ui||none?'warn':'ok');\n" + "var list,side=1;\n" - + "if(s.mode==='paused'||!(s.cells||s.single))" + + "if(s.mode==='paused'||!(s.cells||s.single||s.slots))" + "list=[{text:s.mode==='paused'?'App in background':(s.mode||'')}];\n" + // Single and U view (#100): the grid with the camera on screen and the checks of + // the others + + "else if(s.slots){list=s.slots.slice();side=list.length>4?3:2}\n" // The U view is the U cell alone; the 3x3 grid has it as its ninth cell (#87) + "else if(s.cells){side=s.mode==='udp'?1:s.cells.length>4?3:2;list=s.cells.slice();" + "while(list.length snapshotCells(int[] side) { side[0] = 1; return cells; } - if (udpViewShown) { - cells.add(snapshotCell(U_CELL)); - side[0] = 1; - return cells; - } - if (!quadEnabled) { - Snapshots.Cell c = new Snapshots.Cell(); - c.slot = mActive + 1; - c.live = mResumed && "connected".equals(singleState) - && singleRenderedWithin(SNAPSHOT_LIVE_MS); - c.label = statusText != null && statusText.getVisibility() == View.VISIBLE - ? statusText.getText().toString() : c.live ? "Playing" : "No new frames"; - if (c.live) { - c.frame = mSurface.getBitmap(width, Snapshots.height(width)); + if (udpViewShown || !quadEnabled) { + // The grid as in multi-view (#100): the camera on screen, the others' checks + updateProbes(); + int size = probeGridSize(); + long now = SystemClock.elapsedRealtime(); + for (int i = 0; i < size * size; i++) { + Snapshots.Cell c; + if (!onScreen(i)) { + c = new Snapshots.Cell(); + c.slot = i == U_CELL ? 0 : i + 1; + StreamProbe.Result r = probeResult(i); + c.label = probeText(i, now); + c.ok = r != null && r.ok; + } else if (udpViewShown) { + c = snapshotCell(U_CELL); + } else { + c = new Snapshots.Cell(); + c.slot = i + 1; + c.live = "connected".equals(singleState) + && singleRenderedWithin(SNAPSHOT_LIVE_MS); + c.label = statusText != null && statusText.getVisibility() == View.VISIBLE + ? statusText.getText().toString() : c.live ? "Playing" : "No new frames"; + if (c.live) { + c.frame = mSurface.getBitmap(width / size, Snapshots.height(width) / size); + } + } + c.onScreen = onScreen(i); + cells.add(c); } - cells.add(c); - side[0] = 1; + side[0] = size; return cells; } int size = quadGridSize(mHosts, DEFAULT_URL); @@ -1500,9 +1522,111 @@ private Map debugState() { } m.put("cells", list); } + if (udpViewShown || !quadEnabled) { + updateProbes(); + m.put("slots", probeSlots()); + } return m; } + // ====================== Stream checks in single view (#100) ====================== + + /** Single and U view: the debug page is in use, the other slots get short checks. */ + private void updateProbes() { + String[] urls = new String[CAM_COUNT]; + for (int i = 0; i < CAM_COUNT; i++) { + urls[i] = mHosts[i] != null && !mHosts[i].isEmpty() && !mHosts[i].equals(DEFAULT_URL) + ? mHosts[i] : null; + } + streamProbes.update(urls, mTransports, udpViewShown ? null : mHost, + mResumed && (udpViewShown || !quadEnabled)); + } + + /** The grid of the debug page in single and U view: 3x3 in the U view (it has the U cell). */ + private int probeGridSize() { + return udpViewShown ? 3 : quadGridSize(mHosts, DEFAULT_URL); + } + + /** Cell i shows what the TV shows: the active slot, or the U cell in the U view. */ + private boolean onScreen(int i) { + return udpViewShown ? i == U_CELL : i == mActive; + } + + private StreamProbe.Result probeResult(int i) { + return i < CAM_COUNT && mHosts[i] != null ? streamProbes.result(i, mHosts[i]) : null; + } + + /** What a cell not on screen says: its last check, or why it has none. */ + private String probeText(int i, long nowMs) { + if (i == U_CELL) return "U \u00b7 not checked in single view"; + String skip = StreamProbes.skipReason(mHosts[i], DEFAULT_URL, + udpViewShown ? null : mHost); + if (skip != null) return skip; + StreamProbe.Result r = probeResult(i); + if (r != null) return r.text(nowMs) + (streamProbes.isRunning(i) ? " \u00b7 checking" : ""); + return streamProbes.isRunning(i) ? "Checking stream\u2026" : "Waiting for a stream check"; + } + + /** + * Every cell of the grid for /stats.json in single and U view (#100): the camera on screen + * as it plays, the others with the result of their last check. UI thread only. + */ + private List probeSlots() { + int size = probeGridSize(); + long now = SystemClock.elapsedRealtime(); + List list = new ArrayList<>(); + for (int i = 0; i < size * size; i++) { + boolean u = i == U_CELL; + Map c = new LinkedHashMap<>(); + c.put("slot", u ? "U" : i + 1); + c.put("on_screen", onScreen(i)); + String url = u ? UDP_CELL_URL : mHosts[i]; + if (u || (url != null && !url.isEmpty() && !url.equals(DEFAULT_URL))) { + c.put("url", u ? UDP_CELL_URL : DebugServer.redact(url)); + if (!u) c.put("transport_mode", mTransports[i] != null ? mTransports[i].name() : null); + } + if (onScreen(i)) { + if (udpViewShown) { + QuadCell[] cells = quadCells; + QuadCell cell = cells != null ? cells[U_CELL] : null; + TextView v = quadStats[U_CELL]; + c.put("video", cell != null && cell.showsVideo(SNAPSHOT_LIVE_MS)); + c.put("text", v != null ? v.getText().toString() : null); + } else { + boolean video = mResumed && "connected".equals(singleState) + && singleRenderedWithin(SNAPSHOT_LIVE_MS); + c.put("video", video); + c.put("text", video ? (mVideoWidth > 0 ? mVideoWidth + "\u00d7" + mVideoHeight + " " : "") + + (rtspClient != null && rtspClient.isJpeg() ? "MJPEG" + : codecH265 ? "H.265" : "H.264") + " \u00b7 " + singleOutFps + " fps" + : statusText != null && statusText.getVisibility() == View.VISIBLE + ? statusText.getText().toString() : singleState); + } + } else { + c.put("video", false); + c.put("text", probeText(i, now)); + StreamProbe.Result r = probeResult(i); + Map p = new LinkedHashMap<>(); + p.put("state", r != null ? (r.ok ? "ok" : "error") + : streamProbes.isRunning(i) ? "checking" : u || StreamProbes.skipReason( + mHosts[i], DEFAULT_URL, udpViewShown ? null : mHost) != null + ? "not checked" : "waiting"); + if (r != null) { + p.put("checked_s_ago", (now - r.atMs) / 1000); + p.put("summary", r.summary); + if (r.codec != null) p.put("codec", r.codec); + if (r.width > 0) p.put("size", r.width + "x" + r.height); + p.put("fps", r.fps); + p.put("kbps", r.kbps); + p.put("key_frames", r.keyFrames); + } + c.put("probe", p); + } + list.add(c); + } + return list; + } + // ====================== Screen / zoom ====================== private static final int REQUEST_STORAGE_FOR_SCREENSHOT = 1; @@ -1806,6 +1930,7 @@ private boolean skipForKeyFrames(Frame buffer) { protected void onPause() { super.onPause(); mResumed = false; + streamProbes.stopAll(); menuManager.onPauseDismissBrowser(); // The UDP view is not a saved mode: after a resume the slot or grid comes back leaveUdpView(); @@ -1958,6 +2083,8 @@ private void startStreams() { mSurface.setVisibility(View.VISIBLE); boolean configured = mHost != null && !mHost.isEmpty() && !mHost.equals(DEFAULT_URL); if (configured) { + // A probe of this camera ends first: some take one client only (#100) + streamProbes.willShow(mHost); rtspClient.configure(mHost, mUserAgent, mTransports[mActive]); startListener(); rtspClient.start(); diff --git a/android/app/src/main/java/com/openipc/decoder/Snapshots.java b/android/app/src/main/java/com/openipc/decoder/Snapshots.java index 003f44ce..d76cb26c 100644 --- a/android/app/src/main/java/com/openipc/decoder/Snapshots.java +++ b/android/app/src/main/java/com/openipc/decoder/Snapshots.java @@ -58,6 +58,10 @@ static final class Cell { /** Playing right now; otherwise only the label is drawn. */ boolean live; String label; + /** Not on screen, but its last stream check found the stream (#100): label in green. */ + boolean ok; + /** What the TV shows, in a grid drawn for the single view (#100): framed. */ + boolean onScreen; } private static HandlerThread copyThread; @@ -137,6 +141,13 @@ static byte[] render(List cells, int side, int width, boolean labels) { drawLabel(canvas, c, shown, x, y, cw, ch, bandH, text, band); canvas.drawRect(x, y, x + cw, y + 1, grid); canvas.drawRect(x, y, x + 1, y + ch, grid); + if (c.onScreen && side > 1) { + Paint frame = new Paint(); + frame.setColor(0xFF3D8BFF); + frame.setStyle(Paint.Style.STROKE); + frame.setStrokeWidth(4); + canvas.drawRect(x + 2, y + 2, x + cw - 2, y + ch - 2, frame); + } } } long t1 = SystemClock.elapsedRealtime(); @@ -167,8 +178,9 @@ private static void drawLabel(Canvas canvas, Cell c, boolean shown, int x, int y text.setColor(Color.WHITE); canvas.drawText(label, x + 4, y + ch - 6, text); } else { - // No video: slot number and status in the middle, in red - text.setColor(0xFFFF6060); + // No video: slot number and status in the middle, in red; a stream found by a + // check of a slot not on screen in green (#100) + text.setColor(c.ok ? 0xFF7FD07F : 0xFFFF6060); float w = text.measureText(label); canvas.drawText(label, x + Math.max(4, (cw - w) / 2), y + (ch + text.getTextSize()) / 2f, text); diff --git a/android/app/src/main/java/com/openipc/decoder/StreamProbe.java b/android/app/src/main/java/com/openipc/decoder/StreamProbe.java new file mode 100644 index 00000000..6d8ea235 --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/StreamProbe.java @@ -0,0 +1,318 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * StreamProbe.java — a short look at a camera's stream without decoding it (#100) + * + */ + +package com.openipc.decoder; + +import android.os.SystemClock; + +import java.util.Locale; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * One check of a slot that is not on screen, for the debug page in single view: an ordinary + * RTSP session (or an HTTP MJPEG request) without audio, a few seconds of packets counted, + * then closed. Nothing is decoded and no decoder is taken from the stream on screen. + */ +final class StreamProbe { + /** Connect, handshake and PLAY together; a camera slower than this counts as not answering. */ + static final long CONNECT_WINDOW_MS = 8000; + /** Packets are counted this long after PLAY. */ + static final long RECEIVE_MS = 3000; + + /** What one probe found; immutable once made. */ + static final class Result { + /** The slot's URL when it was checked: a result for another URL is not shown. */ + final String url; + /** When the probe ended (elapsedRealtime). */ + final long atMs; + final boolean ok; + /** Codec, size, frame rate and bitrate when ok; otherwise why not. */ + final String summary; + final String codec; + final int width; + final int height; + final float fps; + final int keyFrames; + final long kbps; + + Result(String url, long atMs, boolean ok, String summary, Counter c) { + this.url = url; + this.atMs = atMs; + this.ok = ok; + this.summary = summary; + this.codec = c != null ? c.codec() : null; + this.width = c != null ? c.width : 0; + this.height = c != null ? c.height : 0; + this.fps = c != null ? c.fps() : 0; + this.keyFrames = c != null ? c.keyFrames : 0; + this.kbps = c != null ? c.kbps() : 0; + } + + /** One line for a cell: the result and how long ago it was found. */ + String text(long nowMs) { + return (ok ? "Stream OK \u00b7 " : "") + summary + " \u00b7 checked " + + age(nowMs - atMs) + " ago"; + } + } + + /** "12 s", "3 min": the age of a result. */ + static String age(long ms) { + long s = Math.max(0, ms) / 1000; + return s < 120 ? s + " s" : s / 60 + " min"; + } + + /** + * Counts what arrives on the video channel: packets, frames (marker bits), key frames and + * bytes. The size is known only for MJPEG: H.264/H.265 would need the SPS parsed or a + * decoder, and the probe has none. + */ + static final class Counter { + int videoPt = -1; + boolean h265; + boolean jpeg; + int packets; + int frames; + int keyFrames; + long bytes; + int width; + int height; + long firstMs = -1; + long lastFrameMs = -1; + long firstFrameMs = -1; + private boolean keyInFrame; + + void onSdp(Sdp sdp) { + videoPt = sdp.videoPt; + h265 = sdp.videoH265; + jpeg = sdp.videoJpeg; + } + + /** One RTP packet of the video channel, at {@code nowMs}. */ + void onPacket(byte[] buf, int offset, int length, long nowMs) { + int header = RtspSession.rtpHeaderLength(buf, offset, length); + if (header < 0 || (buf[offset + 1] & 0x7F) != videoPt) return; + int end = RtspSession.rtpPayloadEnd(buf, offset, length); + if (end < header) return; + packets++; + bytes += length; + if (firstMs < 0) firstMs = nowMs; + int start = offset + header; + end += offset; + if (jpeg) { + // RTP/JPEG header (RFC 2435): width and height in 8-pixel units at bytes 6 and 7 + if (end - start >= 8 && width == 0) { + width = (buf[start + 6] & 0xFF) * 8; + height = (buf[start + 7] & 0xFF) * 8; + } + } else if (keyNal(buf, start, end, h265)) { + keyInFrame = true; + } + // The marker bit ends an access unit: one frame + if ((buf[offset + 1] & 0x80) != 0) frameEnd(nowMs); + } + + /** One whole JPEG of an HTTP MJPEG slot. */ + void onJpegFrame(byte[] buf, int offset, int length, long nowMs) { + jpeg = true; + packets++; + bytes += length; + if (firstMs < 0) firstMs = nowMs; + if (width == 0) { + byte[] head = offset == 0 ? buf + : java.util.Arrays.copyOfRange(buf, offset, offset + Math.min(length, 4096)); + int[] size = JpegAssembler.size(head, Math.min(length, head.length)); + if (size != null) { + width = size[0]; + height = size[1]; + } + } + frameEnd(nowMs); + } + + private void frameEnd(long nowMs) { + frames++; + if (keyInFrame) keyFrames++; + keyInFrame = false; + if (firstFrameMs < 0) firstFrameMs = nowMs; + lastFrameMs = nowMs; + } + + /** A key frame NAL unit in this payload: alone, first piece of a fragment, or aggregated. */ + static boolean keyNal(byte[] buf, int start, int end, boolean h265) { + int nalHeader = h265 ? 2 : 1; + if (end - start < nalHeader) return false; + int type = NalAssembler.fragment(buf[start], h265); + if (h265 ? type == 49 : type == 28) { + // FU: the start bit and the type of the unit it carries + if (end - start <= nalHeader) return false; + int fu = buf[start + nalHeader] & 0xFF; + int inner = h265 ? fu & 0x3F : fu & 0x1F; + return (fu & 0x80) != 0 && QuadCell.isKeyFrameNal(inner, h265); + } + if (h265 ? type == 48 : type == 24) { + // Aggregation: 16-bit size, then the unit, for each unit + int pos = start + nalHeader; + while (end - pos >= 2 + nalHeader) { + int size = ((buf[pos] & 0xFF) << 8) | (buf[pos + 1] & 0xFF); + pos += 2; + if (size < nalHeader || size > end - pos) return false; + if (QuadCell.isKeyFrameNal(NalAssembler.fragment(buf[pos], h265), h265)) { + return true; + } + pos += size; + } + return false; + } + return QuadCell.isKeyFrameNal(type, h265); + } + + String codec() { + return videoPt < 0 && !jpeg ? null : jpeg ? "MJPEG" : h265 ? "H.265" : "H.264"; + } + + /** Frames per second between the first and the last frame; 0 below two frames. */ + float fps() { + if (frames < 2 || lastFrameMs <= firstFrameMs) return 0; + return (frames - 1) * 1000f / (lastFrameMs - firstFrameMs); + } + + /** Video bitrate in kbit/s from the first packet to the last frame. */ + long kbps() { + if (firstMs < 0 || lastFrameMs <= firstMs) return 0; + return bytes * 8 / (lastFrameMs - firstMs); + } + + /** "1280×720 MJPEG · 5 fps · 2.1 Mbit/s"; size only when known. */ + String summary() { + StringBuilder sb = new StringBuilder(); + if (width > 0) sb.append(width).append('\u00d7').append(height).append(' '); + sb.append(codec() != null ? codec() : "video"); + float f = fps(); + if (f > 0) { + sb.append(" \u00b7 ").append(f < 10 ? String.format(Locale.ROOT, "%.1f", f) + : String.valueOf(Math.round(f))).append(" fps"); + } + long k = kbps(); + if (k > 0) { + sb.append(" \u00b7 ").append(k < 1000 ? k + " kbit/s" + : String.format(Locale.ROOT, "%.1f Mbit/s", k / 1000f)); + } + return sb.toString(); + } + } + + /** + * Result of a probe from what it saw: no PLAY (the last error, or no answer), PLAY but no + * video packets, or the stream. + */ + static Result result(String url, long atMs, boolean played, String failure, Counter c) { + if (!played) { + return new Result(url, atMs, false, failure != null ? QuadCell.shortError(failure) + : "No answer from camera", null); + } + if (c.frames == 0) { + return new Result(url, atMs, false, c.packets > 0 + ? "Packets but no whole frame" : "PLAY OK, but no video", c); + } + return new Result(url, atMs, true, c.summary(), c); + } + + /** Asked between steps: the probe stops early, e.g. the TV switched to that camera. */ + interface Cancel { + boolean cancelled(); + } + + private StreamProbe() {} + + /** + * Runs one probe on the calling thread, at most CONNECT_WINDOW_MS + RECEIVE_MS. Returns + * null when cancelled: a cut-short look says nothing. + */ + static Result run(String tag, String url, Transport.Mode mode, String userAgent, Cancel cancel) { + Counter counter = new Counter(); + CountDownLatch played = new CountDownLatch(1); + String[] failure = new String[1]; + RtspSession session = new RtspSession(tag, new RtspSession.Callback() { + @Override + public void onStatus(String status) { + if ("buffering".equals(status) || "connected".equals(status)) played.countDown(); + } + + @Override + public void onFailure(String message) { + failure[0] = message; + } + + @Override + public void onSdp(Sdp sdp) { + synchronized (counter) { + counter.onSdp(sdp); + } + } + + @Override + public boolean onPacket(int channel, byte[] buf, int offset, int length) { + if (channel != RtspSession.CHANNEL_VIDEO) return false; + synchronized (counter) { + counter.onPacket(buf, offset, length, SystemClock.elapsedRealtime()); + } + return true; + } + + @Override + public boolean onJpegFrame(byte[] buf, int offset, int length) { + synchronized (counter) { + counter.onJpegFrame(buf, offset, length, SystemClock.elapsedRealtime()); + } + return true; + } + }); + // Auto would wait for UDP first: a probe takes TCP, the safe choice for any camera + Transport.Mode m = mode == null || mode == Transport.Mode.AUTO ? Transport.Mode.TCP : mode; + session.configure(url, userAgent, false, m); + session.start(); + boolean ok = false; + try { + ok = waitFor(played, CONNECT_WINDOW_MS, cancel); + if (ok && !cancel.cancelled()) sleep(RECEIVE_MS, cancel); + } finally { + session.stop(); + } + if (cancel.cancelled()) return null; + synchronized (counter) { + return result(url, SystemClock.elapsedRealtime(), ok, failure[0], counter); + } + } + + private static boolean waitFor(CountDownLatch latch, long ms, Cancel cancel) { + long end = SystemClock.elapsedRealtime() + ms; + try { + while (!cancel.cancelled()) { + long left = end - SystemClock.elapsedRealtime(); + if (left <= 0) return false; + if (latch.await(Math.min(left, 200), TimeUnit.MILLISECONDS)) return true; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return false; + } + + private static void sleep(long ms, Cancel cancel) { + long end = SystemClock.elapsedRealtime() + ms; + try { + while (!cancel.cancelled()) { + long left = end - SystemClock.elapsedRealtime(); + if (left <= 0) return; + Thread.sleep(Math.min(left, 200)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/android/app/src/main/java/com/openipc/decoder/StreamProbes.java b/android/app/src/main/java/com/openipc/decoder/StreamProbes.java new file mode 100644 index 00000000..c28cec1a --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/StreamProbes.java @@ -0,0 +1,247 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * StreamProbes.java — short stream checks of the slots not on screen, for the debug page (#100) + * + */ + +package com.openipc.decoder; + +import android.os.SystemClock; +import android.util.Log; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * In single view only one camera has a session, so the debug page would know nothing about + * the others. While the page is in use (it asks for the state every 1.5 s, which calls + * {@link #update}), each other configured slot gets a {@link StreamProbe} every PERIOD_MS, + * one or two at a time. A slot of the camera on screen is never probed: some cameras take + * one RTSP client only (XM). Nothing runs once the page has not asked for IDLE_MS, or in + * multi-view, where every cell has its own session. + */ +final class StreamProbes { + private static final String TAG = "OpenIPCDecoder"; + /** A slot is checked again this long after its last check began. */ + static final long PERIOD_MS = 45_000; + /** No request from the debug page for this long: no new probes. */ + static final long IDLE_MS = 60_000; + /** Probes at the same time: a few sockets, never more load than that. */ + static final int MAX_RUNNING = 2; + private static final long TICK_MS = 2000; + + /** Why a slot gets no probe, or null if it gets one. */ + static String skipReason(String url, String defaultUrl, String screenUrl) { + if (url == null || url.trim().isEmpty() || url.equals(defaultUrl)) return "not configured"; + // A listening slot would take the port; the camera pushes to it, nothing to ask + if (RtpListener.Url.matches(url)) return "UDP listen slot: not checked in single view"; + String e = endpoint(url); + if (e == null) return "invalid URL"; + if (e.equals(endpoint(screenUrl))) return "same camera as on screen"; + return null; + } + + /** "host:port" of an RTSP or HTTP URL, lower case, or null. */ + static String endpoint(String url) { + if (url == null) return null; + try { + if (HttpMjpeg.matches(url)) { + HttpMjpeg.Url u = HttpMjpeg.Url.parse(url); + return u.host.toLowerCase(Locale.ROOT) + ":" + u.port; + } + if (RtpListener.Url.matches(url)) return null; + RtspSession.Url u = RtspSession.Url.parse(url); + return u.host == null || u.host.isEmpty() ? null + : u.host.toLowerCase(Locale.ROOT) + ":" + u.port; + } catch (IOException | RuntimeException e) { + return null; + } + } + + /** + * The slot to probe next, or -1: one that may be probed, is not running, has no running + * probe to the same camera, and whose last start is at least PERIOD_MS ago; the one + * waiting longest first (never checked before all others), the lower slot on a tie. + */ + static int next(String[] urls, String[] skip, long[] lastStartMs, boolean[] running, + long nowMs) { + Set busy = new HashSet<>(); + for (int i = 0; i < urls.length; i++) { + if (running[i]) busy.add(endpoint(urls[i])); + } + int best = -1; + for (int i = 0; i < urls.length; i++) { + if (skip[i] != null || running[i] || busy.contains(endpoint(urls[i]))) continue; + if (lastStartMs[i] > 0 && nowMs - lastStartMs[i] < PERIOD_MS) continue; + if (best < 0 || lastStartMs[i] < lastStartMs[best]) best = i; + } + return best; + } + + private final String userAgent; + private final Object lock = new Object(); + // Guarded by lock: the slots as of the last update + private String[] urls = new String[0]; + private Transport.Mode[] modes = new Transport.Mode[0]; + private String screenUrl; + private boolean wanted; + private long lastUpdateMs; + private long[] lastStartMs = new long[0]; + private final boolean[] running = new boolean[Decoder.CAM_COUNT]; + private final Probe[] probes = new Probe[Decoder.CAM_COUNT]; + private ScheduledFuture ticker; + + private final ConcurrentHashMap results = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(1, r -> { + Thread t = new Thread(r, "probe-sched"); + t.setDaemon(true); + return t; + }); + private final ExecutorService workers = new ThreadPoolExecutor(MAX_RUNNING, MAX_RUNNING, + 30, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), r -> { + // Background priority: the stream on screen comes first + Thread t = new Thread(() -> { + android.os.Process.setThreadPriority( + android.os.Process.THREAD_PRIORITY_BACKGROUND); + r.run(); + }, "probe"); + t.setDaemon(true); + return t; + }); + + /** A running probe; cancelled when its camera goes on screen or probing stops. */ + private static final class Probe implements StreamProbe.Cancel { + final String url; + volatile boolean cancelled; + + Probe(String url) { this.url = url; } + + @Override public boolean cancelled() { return cancelled; } + } + + StreamProbes(String userAgent) { + this.userAgent = userAgent; + ((ThreadPoolExecutor) workers).allowCoreThreadTimeOut(true); + } + + /** + * The slots now (UI thread, from the debug requests): {@code wanted} in single view and the + * U view, false otherwise; {@code screenUrl} the camera on screen, or null. + */ + void update(String[] slotUrls, Transport.Mode[] slotModes, String screen, boolean want) { + synchronized (lock) { + urls = slotUrls.clone(); + modes = slotModes.clone(); + screenUrl = screen; + wanted = want; + lastUpdateMs = SystemClock.elapsedRealtime(); + if (lastStartMs.length != urls.length) lastStartMs = new long[urls.length]; + cancelStaleLocked(); + if (want && ticker == null) { + ticker = scheduler.scheduleWithFixedDelay(this::tick, 0, TICK_MS, + TimeUnit.MILLISECONDS); + } + } + } + + /** The TV is about to connect to this camera: a probe of it ends now. */ + void willShow(String url) { + synchronized (lock) { + screenUrl = url; + cancelStaleLocked(); + } + } + + /** Multi-view or the app going away: running probes end now, no new ones. */ + void stopAll() { + synchronized (lock) { + wanted = false; + cancelStaleLocked(); + } + } + + /** The result for a slot, if it was found for the slot's current URL. */ + StreamProbe.Result result(int slot, String url) { + StreamProbe.Result r = results.get(slot); + return r != null && r.url.equals(url) ? r : null; + } + + /** The slot is being checked right now. */ + boolean isRunning(int slot) { + synchronized (lock) { + return slot < running.length && running[slot]; + } + } + + /** Ends probes that must not go on: probing stopped, or their camera is on screen now. */ + private void cancelStaleLocked() { + String screen = StreamProbes.endpoint(screenUrl); + for (int i = 0; i < probes.length; i++) { + Probe p = probes[i]; + if (p == null) continue; + String e = endpoint(p.url); + boolean slotChanged = i >= urls.length || !p.url.equals(urls[i]); + if (!wanted || slotChanged || (e != null && e.equals(screen))) p.cancelled = true; + } + } + + private void tick() { + synchronized (lock) { + long now = SystemClock.elapsedRealtime(); + if (!wanted || now - lastUpdateMs > IDLE_MS) { + // The page went away or the grid is back: stop asking cameras + wanted = false; + cancelStaleLocked(); + if (ticker != null) ticker.cancel(false); + ticker = null; + return; + } + int count = 0; + for (boolean r : running) if (r) count++; + if (count >= MAX_RUNNING) return; + String[] skip = new String[urls.length]; + for (int i = 0; i < urls.length; i++) skip[i] = skipReason(urls[i], null, screenUrl); + boolean[] run = java.util.Arrays.copyOf(running, urls.length); + // One new probe per tick: the checks are spread out, never started all at once + int slot = next(urls, skip, lastStartMs, run, now); + if (slot < 0) return; + lastStartMs[slot] = now; + running[slot] = true; + Probe p = new Probe(urls[slot]); + probes[slot] = p; + Transport.Mode mode = slot < modes.length ? modes[slot] : null; + workers.execute(() -> runProbe(slot, p, mode)); + } + } + + private void runProbe(int slot, Probe p, Transport.Mode mode) { + StreamProbe.Result r = null; + try { + r = StreamProbe.run("Probe-" + (slot + 1), p.url, mode, userAgent, p); + } catch (RuntimeException e) { + Log.w(TAG, "Probe-" + (slot + 1) + " failed", e); + } finally { + synchronized (lock) { + if (probes[slot] == p) { + probes[slot] = null; + running[slot] = false; + } + } + } + if (r != null) { + results.put(slot, r); + Log.i(TAG, "Probe-" + (slot + 1) + ": " + (r.ok ? "stream OK, " : "") + r.summary); + } + } +} diff --git a/android/app/src/test/java/com/openipc/decoder/StreamProbeTest.java b/android/app/src/test/java/com/openipc/decoder/StreamProbeTest.java new file mode 100644 index 00000000..624b10e5 --- /dev/null +++ b/android/app/src/test/java/com/openipc/decoder/StreamProbeTest.java @@ -0,0 +1,189 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * StreamProbeTest.java — stream checks of the slots not on screen (#100) + * + */ + +package com.openipc.decoder; + +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; + +public class StreamProbeTest { + private static final String DEF = "rtsp://root:12345@192.168.1.10:554/stream=0"; + + /** RTP packet: version 2, payload type, marker, then the payload bytes. */ + private static byte[] rtp(int pt, boolean marker, int... payload) { + byte[] p = new byte[12 + payload.length]; + p[0] = (byte) 0x80; + p[1] = (byte) ((marker ? 0x80 : 0) | pt); + for (int i = 0; i < payload.length; i++) p[12 + i] = (byte) payload[i]; + return p; + } + + private static StreamProbe.Counter counter(int pt, boolean h265, boolean jpeg) { + StreamProbe.Counter c = new StreamProbe.Counter(); + Sdp sdp = new Sdp(); + sdp.videoPt = pt; + sdp.videoH265 = h265; + sdp.videoJpeg = jpeg; + c.onSdp(sdp); + return c; + } + + private static void feed(StreamProbe.Counter c, byte[] p, long ms) { + c.onPacket(p, 0, p.length, ms); + } + + @Test + public void framesAreCountedByTheMarkerBit() { + StreamProbe.Counter c = counter(96, false, false); + for (int i = 0; i < 26; i++) { + feed(c, rtp(96, false, 0x41, 1, 2), i * 40); + feed(c, rtp(96, true, 0x41, 3, 4), i * 40); + } + assertEquals(52, c.packets); + assertEquals(26, c.frames); + assertEquals(25f, c.fps(), 0.01f); + assertEquals("H.264 · 25 fps · 6 kbit/s", c.summary()); + } + + @Test + public void otherPayloadTypesAreIgnored() { + StreamProbe.Counter c = counter(96, false, false); + feed(c, rtp(97, true, 0x65), 0); + feed(c, rtp(96, true, 0x41), 0); + assertEquals(1, c.packets); + assertEquals(1, c.frames); + } + + @Test + public void h264KeyFramesAloneFragmentedOrAggregated() { + StreamProbe.Counter c = counter(96, false, false); + // IDR alone + feed(c, rtp(96, true, 0x65, 0), 0); + // FU-A carrying an IDR: start piece, then the end piece + feed(c, rtp(96, false, 0x7C, 0x85, 0), 40); + feed(c, rtp(96, true, 0x7C, 0x45, 0), 40); + // STAP-A with SPS and IDR + feed(c, rtp(96, true, 0x78, 0, 2, 0x67, 0, 0, 2, 0x65, 0), 80); + // A P frame + feed(c, rtp(96, true, 0x41, 0), 120); + assertEquals(4, c.frames); + assertEquals(3, c.keyFrames); + } + + @Test + public void h265KeyFrames() { + StreamProbe.Counter c = counter(97, true, false); + // IDR_W_RADL (19) alone: header (19 << 1), 1 + feed(c, rtp(97, true, 19 << 1, 1, 0), 0); + // FU (49) with the start bit and type 19 + feed(c, rtp(97, true, 49 << 1, 1, 0x80 | 19, 0), 40); + // TRAIL_R (1) + feed(c, rtp(97, true, 1 << 1, 1, 0), 80); + assertEquals(3, c.frames); + assertEquals(2, c.keyFrames); + assertEquals("H.265", c.codec()); + } + + @Test + public void rtpJpegSizeFromItsHeader() { + StreamProbe.Counter c = counter(26, false, true); + // type-specific, offset (3), type, Q, width / 8, height / 8 + feed(c, rtp(26, true, 0, 0, 0, 0, 1, 255, 160, 90), 0); + feed(c, rtp(26, true, 0, 0, 0, 0, 1, 255, 160, 90), 200); + assertEquals(1280, c.width); + assertEquals(720, c.height); + assertTrue(c.summary(), c.summary().startsWith("1280×720 MJPEG · 5.0 fps")); + } + + @Test + public void httpMjpegFramesAndSize() { + StreamProbe.Counter c = new StreamProbe.Counter(); + byte[] jpeg = JpegAssembler.headers(1, 640, 480, JpegAssembler.tables(50), 0); + c.onJpegFrame(jpeg, 0, jpeg.length, 0); + c.onJpegFrame(jpeg, 0, jpeg.length, 100); + assertEquals(2, c.frames); + assertEquals(640, c.width); + assertEquals(480, c.height); + assertEquals("MJPEG", c.codec()); + assertEquals(10f, c.fps(), 0.01f); + } + + @Test + public void resultTexts() { + StreamProbe.Result noAnswer = StreamProbe.result("u", 1000, false, null, new StreamProbe.Counter()); + assertFalse(noAnswer.ok); + assertEquals("No answer from camera · checked 12 s ago", noAnswer.text(13_000)); + StreamProbe.Result login = StreamProbe.result("u", 0, false, "RTSP DESCRIBE failed: 401", null); + assertEquals("Wrong login or password", login.summary); + StreamProbe.Result silent = StreamProbe.result("u", 0, true, null, counter(96, false, false)); + assertEquals("PLAY OK, but no video", silent.summary); + StreamProbe.Counter c = counter(96, false, false); + feed(c, rtp(96, true, 0x65), 0); + feed(c, rtp(96, true, 0x41), 1000); + StreamProbe.Result r = StreamProbe.result("u", 0, true, null, c); + assertTrue(r.ok); + assertTrue(r.text(180_000), r.text(180_000).startsWith("Stream OK · H.264 · 1.0 fps")); + assertTrue(r.text(180_000).endsWith("checked 3 min ago")); + assertEquals(1, r.keyFrames); + } + + @Test + public void skipReasons() { + assertEquals("not configured", StreamProbes.skipReason(null, DEF, null)); + assertEquals("not configured", StreamProbes.skipReason(DEF, DEF, null)); + assertEquals("UDP listen slot: not checked in single view", + StreamProbes.skipReason("udp://@:5600", DEF, null)); + assertEquals("same camera as on screen", StreamProbes.skipReason( + "rtsp://a:b@10.0.0.53:554/x", DEF, "rtsp://10.0.0.53/y")); + assertNull(StreamProbes.skipReason("rtsp://10.0.0.53:8554/x", DEF, "rtsp://10.0.0.53/y")); + assertNull(StreamProbes.skipReason("http://u:p@10.0.0.133/mjpeg", DEF, "rtsp://10.0.0.133/y")); + assertEquals("same camera as on screen", StreamProbes.skipReason( + "http://10.0.0.133/mjpeg", DEF, "http://u:p@10.0.0.133:80/other")); + } + + @Test + public void endpoints() { + assertEquals("10.0.0.5:554", StreamProbes.endpoint("rtsp://u:p@10.0.0.5/stream=0")); + assertEquals("cam:8554", StreamProbes.endpoint("rtsp://CAM:8554/x")); + assertEquals("10.0.0.5:80", StreamProbes.endpoint("http://10.0.0.5/mjpeg")); + assertNull(StreamProbes.endpoint("udp://@:5600")); + assertNull(StreamProbes.endpoint(null)); + } + + @Test + public void nextSlotWaitsLongestAndNeverTwiceToOneCamera() { + String[] urls = {"rtsp://a/1", "rtsp://b/1", "rtsp://a/2", "rtsp://c/1"}; + String[] skip = new String[4]; + long[] last = new long[4]; + boolean[] running = new boolean[4]; + // Never checked: the lowest slot first + assertEquals(0, StreamProbes.next(urls, skip, last, running, 100_000)); + // Slot 0 runs: slot 2 is the same camera, so slot 1 + running[0] = true; + last[0] = 100_000; + assertEquals(1, StreamProbes.next(urls, skip, last, running, 100_000)); + running[1] = true; + last[1] = 100_000; + assertEquals(3, StreamProbes.next(urls, skip, last, running, 100_000)); + // All done; slot 2 never checked comes first, then the oldest once the period passed + running[0] = running[1] = false; + last[3] = 101_000; + assertEquals(2, StreamProbes.next(urls, skip, last, running, 102_000)); + last[2] = 102_000; + assertEquals(-1, StreamProbes.next(urls, skip, last, running, 102_000 + 1000)); + assertEquals(0, StreamProbes.next(urls, skip, last, running, + 100_000 + StreamProbes.PERIOD_MS)); + // A skipped slot is never chosen + skip[0] = "same camera as on screen"; + assertEquals(1, StreamProbes.next(urls, skip, last, running, + 100_000 + StreamProbes.PERIOD_MS)); + } +} From 7d711371b69a2c1a27e7eae84136886918cdaaa1 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Wed, 14 Oct 2026 14:36:04 +0000 Subject: [PATCH 2/6] fix: snapshot frame paint name clashed with the frame bitmap (#100) --- .../src/main/java/com/openipc/decoder/Snapshots.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/Snapshots.java b/android/app/src/main/java/com/openipc/decoder/Snapshots.java index d76cb26c..f64e182d 100644 --- a/android/app/src/main/java/com/openipc/decoder/Snapshots.java +++ b/android/app/src/main/java/com/openipc/decoder/Snapshots.java @@ -124,6 +124,11 @@ static byte[] render(List cells, int side, int width, boolean labels) { Paint frames = new Paint(Paint.FILTER_BITMAP_FLAG); Paint grid = new Paint(); grid.setColor(Color.DKGRAY); + // The camera on screen in a grid drawn for the single view (#100) + Paint onScreen = new Paint(); + onScreen.setColor(0xFF3D8BFF); + onScreen.setStyle(Paint.Style.STROKE); + onScreen.setStrokeWidth(4); int copied = 0; for (int i = 0; i < n; i++) { Cell c = cells.get(i); @@ -142,11 +147,7 @@ static byte[] render(List cells, int side, int width, boolean labels) { canvas.drawRect(x, y, x + cw, y + 1, grid); canvas.drawRect(x, y, x + 1, y + ch, grid); if (c.onScreen && side > 1) { - Paint frame = new Paint(); - frame.setColor(0xFF3D8BFF); - frame.setStyle(Paint.Style.STROKE); - frame.setStrokeWidth(4); - canvas.drawRect(x + 2, y + 2, x + cw - 2, y + ch - 2, frame); + canvas.drawRect(x + 2, y + 2, x + cw - 2, y + ch - 2, onScreen); } } } From 4234ae4e7f7df0d8b690a7135bd15e705cc799f7 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Wed, 14 Oct 2026 15:16:39 +0000 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20Qodo=20on=20#163=20=E2=80=94=20check?= =?UTF-8?q?s=20tied=20to=20URL=20and=20transport,=20empty=20packets=20and?= =?UTF-8?q?=20non-JPEG=20parts=20are=20no=20video,=20a=20cancel=20closes?= =?UTF-8?q?=20the=20check's=20socket=20at=20once,=20transport=20in=20slots?= =?UTF-8?q?,=20threads=20stopped=20with=20the=20activity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/openipc/decoder/Decoder.java | 24 ++++- .../java/com/openipc/decoder/StreamProbe.java | 98 +++++++++++++------ .../com/openipc/decoder/StreamProbes.java | 82 +++++++++++++--- .../com/openipc/decoder/StreamProbeTest.java | 36 ++++++- 4 files changed, 192 insertions(+), 48 deletions(-) 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 58109f14..5719439d 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1553,7 +1553,8 @@ private boolean onScreen(int i) { } private StreamProbe.Result probeResult(int i) { - return i < CAM_COUNT && mHosts[i] != null ? streamProbes.result(i, mHosts[i]) : null; + return i < CAM_COUNT && mHosts[i] != null + ? streamProbes.result(i, mHosts[i], mTransports[i]) : null; } /** What a cell not on screen says: its last check, or why it has none. */ @@ -1567,6 +1568,22 @@ private String probeText(int i, long nowMs) { return streamProbes.isRunning(i) ? "Checking stream\u2026" : "Waiting for a stream check"; } + /** + * Transport of a grid cell in single and U view (Qodo on #163): in use for the camera on + * screen, otherwise what a check of it goes over. + */ + private String slotTransport(int i, String url) { + if (i == U_CELL) return "UDP push"; + if (onScreen(i)) { + return Transport.label(rtspClient != null ? rtspClient.transportInUse() : null, + mTransports[i]); + } + if (RtpListener.Url.matches(url)) return "UDP listen"; + if (HttpMjpeg.matches(url)) return "HTTP"; + Transport.Mode m = StreamProbe.probeMode(mTransports[i]); + return "check over " + (m == Transport.Mode.MULTICAST ? "multicast" : m.name()); + } + /** * Every cell of the grid for /stats.json in single and U view (#100): the camera on screen * as it plays, the others with the result of their last check. UI thread only. @@ -1583,7 +1600,9 @@ private List probeSlots() { String url = u ? UDP_CELL_URL : mHosts[i]; if (u || (url != null && !url.isEmpty() && !url.equals(DEFAULT_URL))) { c.put("url", u ? UDP_CELL_URL : DebugServer.redact(url)); - if (!u) c.put("transport_mode", mTransports[i] != null ? mTransports[i].name() : null); + c.put("transport", slotTransport(i, url)); + c.put("transport_mode", u ? "UDP" : mTransports[i] != null + ? mTransports[i].name() : null); } if (onScreen(i)) { if (udpViewShown) { @@ -2026,6 +2045,7 @@ private void stopExecutor() { protected void onDestroy() { super.onDestroy(); DebugServer.get().clearSource(debugSource); + streamProbes.shutdown(); audioManager.shutdown(); } diff --git a/android/app/src/main/java/com/openipc/decoder/StreamProbe.java b/android/app/src/main/java/com/openipc/decoder/StreamProbe.java index 6d8ea235..7bf99f27 100644 --- a/android/app/src/main/java/com/openipc/decoder/StreamProbe.java +++ b/android/app/src/main/java/com/openipc/decoder/StreamProbe.java @@ -26,8 +26,8 @@ final class StreamProbe { /** What one probe found; immutable once made. */ static final class Result { - /** The slot's URL when it was checked: a result for another URL is not shown. */ - final String url; + /** URL and transport of the check ({@link #key}): a result for another setting is not shown. */ + final String key; /** When the probe ended (elapsedRealtime). */ final long atMs; final boolean ok; @@ -40,8 +40,8 @@ static final class Result { final int keyFrames; final long kbps; - Result(String url, long atMs, boolean ok, String summary, Counter c) { - this.url = url; + Result(String key, long atMs, boolean ok, String summary, Counter c) { + this.key = key; this.atMs = atMs; this.ok = ok; this.summary = summary; @@ -60,6 +60,16 @@ String text(long nowMs) { } } + /** The transport a probe uses: Auto would wait for UDP first, a probe takes TCP. */ + static Transport.Mode probeMode(Transport.Mode mode) { + return mode == null || mode == Transport.Mode.AUTO ? Transport.Mode.TCP : mode; + } + + /** What a result belongs to: the URL and the transport it was checked over. */ + static String key(String url, Transport.Mode mode) { + return url == null ? null : probeMode(mode).name() + " " + url; + } + /** "12 s", "3 min": the age of a result. */ static String age(long ms) { long s = Math.max(0, ms) / 1000; @@ -92,12 +102,15 @@ void onSdp(Sdp sdp) { jpeg = sdp.videoJpeg; } - /** One RTP packet of the video channel, at {@code nowMs}. */ - void onPacket(byte[] buf, int offset, int length, long nowMs) { + /** + * One RTP packet of the video channel, at {@code nowMs}. Returns false for one that + * carries no video: another payload type, or a header without payload (Qodo on #163). + */ + boolean onPacket(byte[] buf, int offset, int length, long nowMs) { int header = RtspSession.rtpHeaderLength(buf, offset, length); - if (header < 0 || (buf[offset + 1] & 0x7F) != videoPt) return; + if (header < 0 || (buf[offset + 1] & 0x7F) != videoPt) return false; int end = RtspSession.rtpPayloadEnd(buf, offset, length); - if (end < header) return; + if (end <= header) return false; packets++; bytes += length; if (firstMs < 0) firstMs = nowMs; @@ -114,24 +127,40 @@ void onPacket(byte[] buf, int offset, int length, long nowMs) { } // The marker bit ends an access unit: one frame if ((buf[offset + 1] & 0x80) != 0) frameEnd(nowMs); + return true; } - /** One whole JPEG of an HTTP MJPEG slot. */ - void onJpegFrame(byte[] buf, int offset, int length, long nowMs) { + /** + * One part of an HTTP MJPEG slot; returns false unless it is a JPEG: it starts with + * SOI and has a frame header or ends with EOI (Qodo on #163). + */ + boolean onJpegFrame(byte[] buf, int offset, int length, long nowMs) { + if (length < 4 || (buf[offset] & 0xFF) != 0xFF || (buf[offset + 1] & 0xFF) != 0xD8) { + return false; + } + byte[] head = offset == 0 ? buf + : java.util.Arrays.copyOfRange(buf, offset, offset + Math.min(length, 4096)); + int[] size = JpegAssembler.size(head, Math.min(length, head.length)); + boolean eoi = endsWithEoi(buf, offset, length); + if (size == null && !eoi) return false; jpeg = true; packets++; bytes += length; if (firstMs < 0) firstMs = nowMs; - if (width == 0) { - byte[] head = offset == 0 ? buf - : java.util.Arrays.copyOfRange(buf, offset, offset + Math.min(length, 4096)); - int[] size = JpegAssembler.size(head, Math.min(length, head.length)); - if (size != null) { - width = size[0]; - height = size[1]; - } + if (width == 0 && size != null) { + width = size[0]; + height = size[1]; } frameEnd(nowMs); + return true; + } + + /** FF D9 at the end, allowing for a few bytes of padding after it. */ + private static boolean endsWithEoi(byte[] buf, int offset, int length) { + for (int i = offset + length - 2; i >= Math.max(offset + 2, offset + length - 8); i--) { + if ((buf[i] & 0xFF) == 0xFF && (buf[i + 1] & 0xFF) == 0xD9) return true; + } + return false; } private void frameEnd(long nowMs) { @@ -210,21 +239,32 @@ String summary() { * Result of a probe from what it saw: no PLAY (the last error, or no answer), PLAY but no * video packets, or the stream. */ - static Result result(String url, long atMs, boolean played, String failure, Counter c) { + static Result result(String key, long atMs, boolean played, String failure, Counter c) { if (!played) { - return new Result(url, atMs, false, failure != null ? QuadCell.shortError(failure) + return new Result(key, atMs, false, failure != null ? QuadCell.shortError(failure) : "No answer from camera", null); } if (c.frames == 0) { - return new Result(url, atMs, false, c.packets > 0 + return new Result(key, atMs, false, c.packets > 0 ? "Packets but no whole frame" : "PLAY OK, but no video", c); } - return new Result(url, atMs, true, c.summary(), c); + return new Result(key, atMs, true, c.summary(), c); } /** Asked between steps: the probe stops early, e.g. the TV switched to that camera. */ interface Cancel { boolean cancelled(); + + /** + * Starts the probe's session unless cancelled already; returns false then. A cancel + * after this stops the session at once rather than at the probe's next look at + * {@link #cancelled()} (Qodo on #163). + */ + default boolean start(RtspSession session) { + if (cancelled()) return false; + session.start(); + return true; + } } private StreamProbe() {} @@ -259,23 +299,19 @@ public void onSdp(Sdp sdp) { public boolean onPacket(int channel, byte[] buf, int offset, int length) { if (channel != RtspSession.CHANNEL_VIDEO) return false; synchronized (counter) { - counter.onPacket(buf, offset, length, SystemClock.elapsedRealtime()); + return counter.onPacket(buf, offset, length, SystemClock.elapsedRealtime()); } - return true; } @Override public boolean onJpegFrame(byte[] buf, int offset, int length) { synchronized (counter) { - counter.onJpegFrame(buf, offset, length, SystemClock.elapsedRealtime()); + return counter.onJpegFrame(buf, offset, length, SystemClock.elapsedRealtime()); } - return true; } }); - // Auto would wait for UDP first: a probe takes TCP, the safe choice for any camera - Transport.Mode m = mode == null || mode == Transport.Mode.AUTO ? Transport.Mode.TCP : mode; - session.configure(url, userAgent, false, m); - session.start(); + session.configure(url, userAgent, false, probeMode(mode)); + if (!cancel.start(session)) return null; boolean ok = false; try { ok = waitFor(played, CONNECT_WINDOW_MS, cancel); @@ -285,7 +321,7 @@ public boolean onJpegFrame(byte[] buf, int offset, int length) { } if (cancel.cancelled()) return null; synchronized (counter) { - return result(url, SystemClock.elapsedRealtime(), ok, failure[0], counter); + return result(key(url, mode), SystemClock.elapsedRealtime(), ok, failure[0], counter); } } diff --git a/android/app/src/main/java/com/openipc/decoder/StreamProbes.java b/android/app/src/main/java/com/openipc/decoder/StreamProbes.java index c28cec1a..3c5260c6 100644 --- a/android/app/src/main/java/com/openipc/decoder/StreamProbes.java +++ b/android/app/src/main/java/com/openipc/decoder/StreamProbes.java @@ -94,6 +94,8 @@ static int next(String[] urls, String[] skip, long[] lastStartMs, boolean[] runn // Guarded by lock: the slots as of the last update private String[] urls = new String[0]; private Transport.Mode[] modes = new Transport.Mode[0]; + /** {@link StreamProbe#key} per slot: URL and probe transport. */ + private String[] keys = new String[0]; private String screenUrl; private boolean wanted; private long lastUpdateMs; @@ -101,6 +103,8 @@ static int next(String[] urls, String[] skip, long[] lastStartMs, boolean[] runn private final boolean[] running = new boolean[Decoder.CAM_COUNT]; private final Probe[] probes = new Probe[Decoder.CAM_COUNT]; private ScheduledFuture ticker; + /** After shutdown(): nothing is scheduled any more. */ + private boolean shut; private final ConcurrentHashMap results = new ConcurrentHashMap<>(); private final ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(1, r -> { @@ -123,16 +127,53 @@ static int next(String[] urls, String[] skip, long[] lastStartMs, boolean[] runn /** A running probe; cancelled when its camera goes on screen or probing stops. */ private static final class Probe implements StreamProbe.Cancel { final String url; - volatile boolean cancelled; + /** {@link StreamProbe#key} of the slot's setting when the probe began. */ + final String key; + private volatile boolean cancelled; + // Guarded by this + private RtspSession session; - Probe(String url) { this.url = url; } + Probe(String url, String key) { + this.url = url; + this.key = key; + } @Override public boolean cancelled() { return cancelled; } + + @Override + public synchronized boolean start(RtspSession s) { + if (cancelled) return false; + session = s; + s.start(); + return true; + } + + /** + * Ends the probe now: its socket is closed before this returns, so the camera has no + * second client once the TV connects (one-client cameras, Qodo on #163). + */ + synchronized void cancel() { + cancelled = true; + if (session != null) session.stop(); + } } StreamProbes(String userAgent) { this.userAgent = userAgent; ((ThreadPoolExecutor) workers).allowCoreThreadTimeOut(true); + // No thread kept while the page is not in use + ((ScheduledThreadPoolExecutor) scheduler).setKeepAliveTime(30, TimeUnit.SECONDS); + ((ScheduledThreadPoolExecutor) scheduler).allowCoreThreadTimeOut(true); + } + + /** The activity goes away: probes end, the threads go (Qodo on #163). */ + void shutdown() { + synchronized (lock) { + shut = true; + } + stopAll(); + scheduler.shutdownNow(); + workers.shutdown(); } /** @@ -141,12 +182,23 @@ private static final class Probe implements StreamProbe.Cancel { */ void update(String[] slotUrls, Transport.Mode[] slotModes, String screen, boolean want) { synchronized (lock) { + if (shut) return; urls = slotUrls.clone(); - modes = slotModes.clone(); + modes = java.util.Arrays.copyOf(slotModes, urls.length); + String[] newKeys = new String[urls.length]; + for (int i = 0; i < urls.length; i++) newKeys[i] = StreamProbe.key(urls[i], modes[i]); + if (lastStartMs.length != urls.length) lastStartMs = new long[urls.length]; + for (int i = 0; i < urls.length; i++) { + // A changed URL or transport: the last check says nothing about it, check soon + if (i < keys.length && !java.util.Objects.equals(keys[i], newKeys[i])) { + results.remove(i); + lastStartMs[i] = 0; + } + } + keys = newKeys; screenUrl = screen; wanted = want; lastUpdateMs = SystemClock.elapsedRealtime(); - if (lastStartMs.length != urls.length) lastStartMs = new long[urls.length]; cancelStaleLocked(); if (want && ticker == null) { ticker = scheduler.scheduleWithFixedDelay(this::tick, 0, TICK_MS, @@ -171,10 +223,10 @@ void stopAll() { } } - /** The result for a slot, if it was found for the slot's current URL. */ - StreamProbe.Result result(int slot, String url) { + /** The result for a slot, if it was found for the slot's current URL and transport. */ + StreamProbe.Result result(int slot, String url, Transport.Mode mode) { StreamProbe.Result r = results.get(slot); - return r != null && r.url.equals(url) ? r : null; + return r != null && r.key.equals(StreamProbe.key(url, mode)) ? r : null; } /** The slot is being checked right now. */ @@ -189,10 +241,10 @@ private void cancelStaleLocked() { String screen = StreamProbes.endpoint(screenUrl); for (int i = 0; i < probes.length; i++) { Probe p = probes[i]; - if (p == null) continue; + if (p == null || p.cancelled()) continue; String e = endpoint(p.url); - boolean slotChanged = i >= urls.length || !p.url.equals(urls[i]); - if (!wanted || slotChanged || (e != null && e.equals(screen))) p.cancelled = true; + boolean slotChanged = i >= keys.length || !p.key.equals(keys[i]); + if (!wanted || slotChanged || (e != null && e.equals(screen))) p.cancel(); } } @@ -218,10 +270,16 @@ private void tick() { if (slot < 0) return; lastStartMs[slot] = now; running[slot] = true; - Probe p = new Probe(urls[slot]); + Probe p = new Probe(urls[slot], keys[slot]); probes[slot] = p; Transport.Mode mode = slot < modes.length ? modes[slot] : null; - workers.execute(() -> runProbe(slot, p, mode)); + try { + workers.execute(() -> runProbe(slot, p, mode)); + } catch (java.util.concurrent.RejectedExecutionException e) { + // Shut down meanwhile + probes[slot] = null; + running[slot] = false; + } } } diff --git a/android/app/src/test/java/com/openipc/decoder/StreamProbeTest.java b/android/app/src/test/java/com/openipc/decoder/StreamProbeTest.java index 624b10e5..181241aa 100644 --- a/android/app/src/test/java/com/openipc/decoder/StreamProbeTest.java +++ b/android/app/src/test/java/com/openipc/decoder/StreamProbeTest.java @@ -54,12 +54,19 @@ public void framesAreCountedByTheMarkerBit() { } @Test - public void otherPayloadTypesAreIgnored() { + public void otherPayloadTypesAndEmptyPacketsAreIgnored() { StreamProbe.Counter c = counter(96, false, false); - feed(c, rtp(97, true, 0x65), 0); - feed(c, rtp(96, true, 0x41), 0); + byte[] other = rtp(97, true, 0x65); + assertFalse(c.onPacket(other, 0, other.length, 0)); + // A marked header without payload is not a frame (Qodo on #163) + byte[] empty = rtp(96, true); + assertFalse(c.onPacket(empty, 0, empty.length, 0)); + byte[] p = rtp(96, true, 0x41); + assertTrue(c.onPacket(p, 0, p.length, 0)); assertEquals(1, c.packets); assertEquals(1, c.frames); + assertEquals("PLAY OK, but no video", + StreamProbe.result("k", 0, true, null, counter(96, false, false)).summary); } @Test @@ -107,6 +114,19 @@ public void rtpJpegSizeFromItsHeader() { public void httpMjpegFramesAndSize() { StreamProbe.Counter c = new StreamProbe.Counter(); byte[] jpeg = JpegAssembler.headers(1, 640, 480, JpegAssembler.tables(50), 0); + assertTrue(c.onJpegFrame(jpeg, 0, jpeg.length, 0)); + assertTrue(c.onJpegFrame(jpeg, 0, jpeg.length, 100)); + // Not a JPEG: no frame (Qodo on #163) + byte[] html = "Login".getBytes(); + assertFalse(c.onJpegFrame(html, 0, html.length, 150)); + // SOI with neither a frame header nor EOI + byte[] cut = {(byte) 0xFF, (byte) 0xD8, 0, 0, 0, 0}; + assertFalse(c.onJpegFrame(cut, 0, cut.length, 150)); + // SOI ... EOI is enough + byte[] tiny = {(byte) 0xFF, (byte) 0xD8, 1, 2, (byte) 0xFF, (byte) 0xD9, '\r', '\n'}; + assertTrue(c.onJpegFrame(tiny, 0, tiny.length, 200)); + assertEquals(3, c.frames); + c = new StreamProbe.Counter(); c.onJpegFrame(jpeg, 0, jpeg.length, 0); c.onJpegFrame(jpeg, 0, jpeg.length, 100); assertEquals(2, c.frames); @@ -135,6 +155,16 @@ public void resultTexts() { assertEquals(1, r.keyFrames); } + @Test + public void resultsBelongToTheUrlAndTheTransportOfTheCheck() { + assertEquals("TCP rtsp://a/1", StreamProbe.key("rtsp://a/1", Transport.Mode.AUTO)); + assertEquals("TCP rtsp://a/1", StreamProbe.key("rtsp://a/1", null)); + assertEquals("UDP rtsp://a/1", StreamProbe.key("rtsp://a/1", Transport.Mode.UDP)); + assertNull(StreamProbe.key(null, Transport.Mode.UDP)); + StreamProbes probes = new StreamProbes(""); + assertNull(probes.result(0, "rtsp://a/1", Transport.Mode.TCP)); + } + @Test public void skipReasons() { assertEquals("not configured", StreamProbes.skipReason(null, DEF, null)); From 3edae3b303b43b03ac8e35391fc67ca32b9bbc5f Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Wed, 14 Oct 2026 15:44:09 +0000 Subject: [PATCH 4/6] fix: an empty slot on the debug page is grey, not red like an error (#100) --- .../app/src/main/java/com/openipc/decoder/DebugServer.java | 5 +++-- android/app/src/main/java/com/openipc/decoder/Decoder.java | 4 ++-- android/app/src/main/java/com/openipc/decoder/Snapshots.java | 5 +++-- .../app/src/main/java/com/openipc/decoder/StreamProbes.java | 5 ++++- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/DebugServer.java b/android/app/src/main/java/com/openipc/decoder/DebugServer.java index bb541879..083c00ae 100644 --- a/android/app/src/main/java/com/openipc/decoder/DebugServer.java +++ b/android/app/src/main/java/com/openipc/decoder/DebugServer.java @@ -447,7 +447,7 @@ private static void closeQuietly(Socket s) { + "text-align:center;padding:8px;font-size:clamp(9px,4cqw,14px)}" + ".off b{font-size:clamp(16px,14cqh,30px);color:#666}" // Single view (#100): the camera on screen framed, a stream found by a check green - + ".cell.on{border:2px solid #3d8bff}.off.ok,.ok{color:#7fd07f}" + + ".cell.on{border:2px solid #3d8bff}.off.ok,.ok{color:#7fd07f}.off.none{color:#888}" + "#list{display:none;width:100%;border-collapse:collapse;font-size:13px}" + "#list td{padding:5px 6px;border-top:1px solid #262626;vertical-align:top}" + "#list td:first-child{font-weight:700;width:1.5em;color:#fff}" @@ -506,7 +506,8 @@ private static void closeQuietly(Socket s) { + "function cell(c){var top=''+esc(c.slot)+''+where(c)+(c.on_screen?' \u00b7 on screen':'')+''," + "cls=c.on_screen?'cell on':'cell';\n" - + "if(!c.video)return '
" + + "if(!c.video)return '
" + "'+esc(c.slot)+''+esc(c.text)+'
'+top+" + "'
';\n" + "return '
'+top+'
debugState() { QuadCell cell = cells[i]; if (cell == null) { c.put("video", false); - c.put("text", "not configured"); + c.put("text", StreamProbes.NOT_CONFIGURED); } else { c.put("video", cell.showsVideo(SNAPSHOT_LIVE_MS)); TextView v = quadStats[i]; diff --git a/android/app/src/main/java/com/openipc/decoder/Snapshots.java b/android/app/src/main/java/com/openipc/decoder/Snapshots.java index f64e182d..58296b35 100644 --- a/android/app/src/main/java/com/openipc/decoder/Snapshots.java +++ b/android/app/src/main/java/com/openipc/decoder/Snapshots.java @@ -180,8 +180,9 @@ private static void drawLabel(Canvas canvas, Cell c, boolean shown, int x, int y canvas.drawText(label, x + 4, y + ch - 6, text); } else { // No video: slot number and status in the middle, in red; a stream found by a - // check of a slot not on screen in green (#100) - text.setColor(c.ok ? 0xFF7FD07F : 0xFFFF6060); + // check of a slot not on screen in green, an empty slot grey (#100) + text.setColor(c.ok ? 0xFF7FD07F + : StreamProbes.NOT_CONFIGURED.equals(c.label) ? 0xFF888888 : 0xFFFF6060); float w = text.measureText(label); canvas.drawText(label, x + Math.max(4, (cw - w) / 2), y + (ch + text.getTextSize()) / 2f, text); diff --git a/android/app/src/main/java/com/openipc/decoder/StreamProbes.java b/android/app/src/main/java/com/openipc/decoder/StreamProbes.java index 3c5260c6..9e3d0946 100644 --- a/android/app/src/main/java/com/openipc/decoder/StreamProbes.java +++ b/android/app/src/main/java/com/openipc/decoder/StreamProbes.java @@ -32,6 +32,9 @@ * multi-view, where every cell has its own session. */ final class StreamProbes { + /** Status of an empty slot: shown grey, not as an error. */ + static final String NOT_CONFIGURED = "not configured"; + private static final String TAG = "OpenIPCDecoder"; /** A slot is checked again this long after its last check began. */ static final long PERIOD_MS = 45_000; @@ -43,7 +46,7 @@ final class StreamProbes { /** Why a slot gets no probe, or null if it gets one. */ static String skipReason(String url, String defaultUrl, String screenUrl) { - if (url == null || url.trim().isEmpty() || url.equals(defaultUrl)) return "not configured"; + if (url == null || url.trim().isEmpty() || url.equals(defaultUrl)) return NOT_CONFIGURED; // A listening slot would take the port; the camera pushes to it, nothing to ask if (RtpListener.Url.matches(url)) return "UDP listen slot: not checked in single view"; String e = endpoint(url); From ca58d892a2d3d631b73f3a13ac73e7ced411b3bc Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Wed, 14 Oct 2026 15:55:05 +0000 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20Qodo=20on=20#163=20=E2=80=94=20a=20f?= =?UTF-8?q?ailed=20check=20ends=20at=20once=20(no=20repeated=20logins=20wi?= =?UTF-8?q?th=20a=20wrong=20password),=20the=20slot=20on=20screen=20beyond?= =?UTF-8?q?=204=20gets=20the=203=C3=973=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/openipc/decoder/Decoder.java | 8 ++++++-- .../main/java/com/openipc/decoder/StreamProbe.java | 11 +++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) 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 92e014dc..96f5897d 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1542,9 +1542,13 @@ private void updateProbes() { mResumed && (udpViewShown || !quadEnabled)); } - /** The grid of the debug page in single and U view: 3x3 in the U view (it has the U cell). */ + /** + * The grid of the debug page in single and U view: 3x3 in the U view (it has the U cell), + * and when the slot on screen is beyond the 2x2 grid, even an empty one (Qodo on #163). + */ private int probeGridSize() { - return udpViewShown ? 3 : quadGridSize(mHosts, DEFAULT_URL); + if (udpViewShown || mActive >= 4) return 3; + return quadGridSize(mHosts, DEFAULT_URL); } /** Cell i shows what the TV shows: the active slot, or the U cell in the U view. */ diff --git a/android/app/src/main/java/com/openipc/decoder/StreamProbe.java b/android/app/src/main/java/com/openipc/decoder/StreamProbe.java index 7bf99f27..a7e3bee9 100644 --- a/android/app/src/main/java/com/openipc/decoder/StreamProbe.java +++ b/android/app/src/main/java/com/openipc/decoder/StreamProbe.java @@ -276,16 +276,23 @@ private StreamProbe() {} static Result run(String tag, String url, Transport.Mode mode, String userAgent, Cancel cancel) { Counter counter = new Counter(); CountDownLatch played = new CountDownLatch(1); + // Played or failed: a failure ends the check at once, before the session's own + // reconnects try the login again (a wrong password would get several attempts per check) + CountDownLatch settled = new CountDownLatch(1); String[] failure = new String[1]; RtspSession session = new RtspSession(tag, new RtspSession.Callback() { @Override public void onStatus(String status) { - if ("buffering".equals(status) || "connected".equals(status)) played.countDown(); + if ("buffering".equals(status) || "connected".equals(status)) { + played.countDown(); + settled.countDown(); + } } @Override public void onFailure(String message) { failure[0] = message; + settled.countDown(); } @Override @@ -314,7 +321,7 @@ public boolean onJpegFrame(byte[] buf, int offset, int length) { if (!cancel.start(session)) return null; boolean ok = false; try { - ok = waitFor(played, CONNECT_WINDOW_MS, cancel); + ok = waitFor(settled, CONNECT_WINDOW_MS, cancel) && played.getCount() == 0; if (ok && !cancel.cancelled()) sleep(RECEIVE_MS, cancel); } finally { session.stop(); From 27e23726ab78c8ceb7c8f54aba95c9248bcf5654 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Wed, 14 Oct 2026 15:55:05 +0000 Subject: [PATCH 6/6] chore: version 2.03 --- android/app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 0f8e6ea3..751e19f5 100755 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -23,8 +23,8 @@ android { applicationId "com.openipc.decoder" minSdk 21 targetSdk 36 - versionCode 27 - versionName "2.02" + versionCode 28 + versionName "2.03" buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\"" }