From b016f154a48e9abe86cd45b4971a8e245dc4f757 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 12:36:32 +0000 Subject: [PATCH 01/13] fix: re-create the video decoder after a failure, with back-off A MediaCodec exception used to set decoderFailed for good: no decoder was created again until the user switched camera, while RTP kept flowing so the watchdog never fired either (fixes #21). - DecoderRetry: the next attempt after a failure waits 0.5 s, 1, 2, 4, 8 s, at most 10 s; a decoded frame resets it. Failed creation backs off the same way. - A new decoder skips input until the first parameter set (SPS/VPS/PPS), instead of getting P-frames it cannot decode. - setEnabled(false) releases the decoder and refuses to create another, so a late decode thread cannot leave one open after a pause or stop. --- .../com/openipc/decoder/DecoderRetry.java | 44 ++++++++++++++++ .../openipc/decoder/MediaCodecManager.java | 50 +++++++++++++++---- 2 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 android/app/src/main/java/com/openipc/decoder/DecoderRetry.java diff --git a/android/app/src/main/java/com/openipc/decoder/DecoderRetry.java b/android/app/src/main/java/com/openipc/decoder/DecoderRetry.java new file mode 100644 index 00000000..b2456a9f --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/DecoderRetry.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * DecoderRetry.java — back-off for re-creating a failed MediaCodec decoder + * + */ + +package com.openipc.decoder; + +/** + * After a decoder failure the next attempt waits 0.5 s, then 1, 2, 4, 8 s, at most 10 s. + * A decoded frame resets the back-off. Thread-safe. + */ +class DecoderRetry { + static final long MIN_DELAY_MS = 500; + static final long MAX_DELAY_MS = 10_000; + + private int failures; + private long notBeforeMs; + + synchronized void onFailure(long nowMs) { + failures++; + notBeforeMs = nowMs + delayMs(failures); + } + + synchronized void onSuccess() { + failures = 0; + notBeforeMs = 0; + } + + synchronized boolean mayCreate(long nowMs) { + return nowMs >= notBeforeMs; + } + + synchronized int failures() { + return failures; + } + + static long delayMs(int failures) { + if (failures <= 0) return 0; + int shift = Math.min(failures - 1, 5); + return Math.min(MAX_DELAY_MS, MIN_DELAY_MS << shift); + } +} diff --git a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java index 734c9f5c..1db09d22 100644 --- a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java @@ -26,7 +26,12 @@ class MediaCodecManager { private volatile MediaCodec decoder; private final Object decoderLock = new Object(); - private volatile boolean decoderFailed; + /** Back-off after failures, instead of giving up for good (#21). */ + private final DecoderRetry retry = new DecoderRetry(); + /** While false no decoder is created, so none can appear after a pause or stop. */ + private volatile boolean enabled = true; + /** A fresh decoder skips input until the first parameter set (SPS/VPS/PPS). */ + private boolean awaitingConfig; private final MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); // Surface set by the owning activity @@ -48,10 +53,18 @@ void setSurface(Surface s) { this.surface = s; } + /** Enable or disable decoding; disabling also releases the decoder. */ + void setEnabled(boolean enabled) { + this.enabled = enabled; + if (!enabled) closeDecoder(); + } + + /** Release the decoder on purpose (codec switch, camera switch): no back-off. */ void closeDecoder() { synchronized (decoderLock) { + retry.onSuccess(); MediaCodec codec = decoder; - if (codec == null) { decoderFailed = false; return; } + if (codec == null) return; decoder = null; Log.i(TAG, "Close video decoder"); try { @@ -60,13 +73,16 @@ void closeDecoder() { } catch (Exception e) { Log.e(TAG, "Decoder close exception", e); } - decoderFailed = false; } } + private static long nowMs() { + return System.nanoTime() / 1_000_000; + } + void createDecoder(boolean codecH265) { synchronized (decoderLock) { - if (decoder != null) return; + if (decoder != null || !enabled || !retry.mayCreate(nowMs())) return; } Surface s = surface; @@ -93,21 +109,27 @@ void createDecoder(boolean codecH265) { throw e; } } catch (Exception e) { - Log.e(TAG, "Cannot setup decoder: " + e.getMessage()); - decoderFailed = true; + retry.onFailure(nowMs()); + Log.e(TAG, "Cannot setup decoder (attempt " + retry.failures() + "): " + e.getMessage()); return; } synchronized (decoderLock) { - if (decoder != null) { + // Disabled meanwhile (pause/stop) or another thread was faster + if (decoder != null || !enabled) { + try { local.stop(); } catch (Exception ignored) {} local.release(); return; } decoder = local; + awaitingConfig = true; } } - /** Decode one NAL frame. Returns true if the decoder was successfully used. */ + /** + * Decode one NAL frame. Returns true if there is no decoder and one may be created now: + * the caller then creates it and passes the same frame again. + */ boolean decodeFrame(Frame buffer, boolean codecH265, long watchdogRef) { if (buffer.length() < 5) { Log.w(TAG, "NAL frame too short: " + buffer.length()); @@ -127,8 +149,12 @@ boolean decodeFrame(Frame buffer, boolean codecH265, long watchdogRef) { synchronized (decoderLock) { MediaCodec codec = decoder; if (codec == null) { - needCreate = !decoderFailed; + needCreate = enabled && retry.mayCreate(nowMs()); + } else if (awaitingConfig && !isConfigNal) { + // P-frames without parameter sets only produce errors in a new decoder + return false; } else { + awaitingConfig = false; try { int inputBufferId = codec.dequeueInputBuffer(5_000); if (inputBufferId >= 0) { @@ -154,12 +180,14 @@ boolean decodeFrame(Frame buffer, boolean codecH265, long watchdogRef) { } } else { codec.releaseOutputBuffer(outputBufferId, true); + retry.onSuccess(); // a decoded frame: the decoder works } } } catch (Exception e) { - Log.e(TAG, "Codec exception: " + e.getMessage()); decoder = null; - decoderFailed = true; + retry.onFailure(nowMs()); + Log.e(TAG, "Codec exception, retry in " + + DecoderRetry.delayMs(retry.failures()) + " ms: " + e.getMessage()); try { codec.stop(); } catch (Exception ignored) {} try { codec.release(); } catch (Exception ignored) {} if (listener != null) listener.onDecoderFailed(); From 90002e5aa2142b926e6388d475f484dc3bddd5ed Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 12:36:32 +0000 Subject: [PATCH 02/13] fix: Quad cells re-create their decoder on the decode thread A Quad cell created its MediaCodecManager once (on the network thread) and returned early from initDecoder() when it already existed. On the first H.265 packet the codec-switch callback closed the decoder, and it was never created again: H.265 cameras stayed black (fixes #20). The cell now owns one MediaCodecManager for its lifetime; the decode thread creates the decoder whenever it is missing and feeds it the frame that triggered creation. stop() disables the manager instead of waiting up to 2 s for the executor. --- .../java/com/openipc/decoder/QuadCell.java | 89 +++++-------------- 1 file changed, 22 insertions(+), 67 deletions(-) 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 031e020e..7fe223d0 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -7,7 +7,6 @@ package com.openipc.decoder; -import android.media.MediaCodec; import android.util.Log; import android.view.Surface; import android.view.TextureView; @@ -36,9 +35,8 @@ class QuadCell implements RtspSession.Callback { private volatile boolean threadsStarted; private volatile Surface surface; - private MediaCodecManager mediaCodecManager; - private final Object decoderLock = new Object(); - private volatile boolean decoderFailed; + /** One decoder manager for the cell's lifetime; decoders are created on the decode thread. */ + private final MediaCodecManager decoder; private final BlockingQueue nalQueue = new ArrayBlockingQueue<>(30); private final NalAssembler nalAssembler; @@ -63,6 +61,13 @@ interface Listener { this.userAgent = userAgent; this.listener = listener; this.session = new RtspSession(tag, this); + this.decoder = new MediaCodecManager(new MediaCodecManager.Listener() { + @Override public void onResolutionChanged(int w, int h) {} + @Override public void onDecoderStarted() { + if (listener != null) listener.onRemoveOverlay(index); + } + @Override public void onDecoderFailed() {} + }); this.nalSink = nal -> { if (!nalQueue.offer(nal)) { // Rate-limited: one aggregation packet can carry many NAL units @@ -72,14 +77,10 @@ interface Listener { pool.recycle(nal); } }; + // On a codec switch the decoder is released; the decode thread creates a new one (#20) this.nalAssembler = new NalAssembler(512 * 1024, () -> { nalQueue.clear(); - synchronized (decoderLock) { - if (mediaCodecManager != null) { - mediaCodecManager.closeDecoder(); - decoderFailed = false; - } - } + decoder.closeDecoder(); }, pool); } @@ -87,6 +88,7 @@ void start() { if (started) return; started = true; running = true; + decoder.setEnabled(true); view.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() { @Override public void onSurfaceTextureAvailable(android.graphics.SurfaceTexture st, int w, int h) { @@ -168,24 +170,13 @@ void stop() { running = false; session.stop(); nalAssembler.reset(); + // Disabling releases the decoder and stops the decode thread from creating another, + // so there is nothing to wait for on the UI thread (#32) + decoder.setEnabled(false); if (executor != null) { executor.shutdownNow(); - try { - if (!executor.awaitTermination(2, TimeUnit.SECONDS)) { - executor.shutdownNow(); - } - } catch (InterruptedException e) { - executor.shutdownNow(); - Thread.currentThread().interrupt(); - } executor = null; } - synchronized (decoderLock) { - if (mediaCodecManager != null) { - mediaCodecManager.closeDecoder(); - } - decoderFailed = false; - } nalQueue.clear(); surface = null; } @@ -201,7 +192,6 @@ public void onSdp(Sdp sdp) { lastUnknownPayload = -1; videoPt = sdp.videoPt; codecH265 = sdp.videoH265; - initDecoder(); } @Override @@ -222,48 +212,13 @@ public boolean onPacket(int channel, byte[] buf, int offset, int length) { private void decode(Frame buffer) { if (buffer.length() < 5) return; - - int flag = 0; - int frag = NalAssembler.fragment(buffer.data()[4], codecH265); - boolean config = codecH265 - ? (frag == MediaCodecManager.H265_NAL_VPS || frag == MediaCodecManager.H265_NAL_SPS || frag == MediaCodecManager.H265_NAL_PPS) - : (frag == MediaCodecManager.H264_NAL_SPS || frag == MediaCodecManager.H264_NAL_PPS); - if (config) flag = MediaCodec.BUFFER_FLAG_CODEC_CONFIG; - - boolean needCreate = false; - synchronized (decoderLock) { - if (mediaCodecManager == null) { - needCreate = !decoderFailed; - } else { - needCreate = mediaCodecManager.decodeFrame(buffer, codecH265, 0); - } - } - if (needCreate) initDecoder(); - } - - private void initDecoder() { - synchronized (decoderLock) { - if (mediaCodecManager != null) return; - } - Surface s = surface; - if (s == null || !s.isValid()) return; - - MediaCodecManager mgr = new MediaCodecManager(new MediaCodecManager.Listener() { - @Override public void onResolutionChanged(int w, int h) {} - @Override public void onDecoderStarted() {} - @Override public void onDecoderFailed() { decoderFailed = true; } - }); - mgr.setSurface(s); - mgr.createDecoder(codecH265); - - synchronized (decoderLock) { - // Another thread was faster, or stop() ran meanwhile (onSdp is on the network thread) - if (mediaCodecManager != null || !running) { - mgr.closeDecoder(); - return; - } - mediaCodecManager = mgr; + if (decoder.decodeFrame(buffer, codecH265, 0)) { + Surface s = surface; + if (s == null || !s.isValid()) return; + decoder.setSurface(s); + decoder.createDecoder(codecH265); + // Feed the frame that triggered creation (often the SPS) to the new decoder + decoder.decodeFrame(buffer, codecH265, 0); } - if (listener != null) listener.onRemoveOverlay(index); } } From 5b6aeb7d4292dcfc500e1e9f346bda33aebf9d82 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 12:36:32 +0000 Subject: [PATCH 03/13] fix: restore camera and Quad mode on resume, no thread leak or long pause - loadSettings() reads back "active" and "quad_enabled", which were saved but never loaded; single view hides the Quad layout again. A shared link opens in single view (fixes #22). - startListener() shuts down the previous executor, so switching camera or saving a URL no longer leaks two idle threads (fixes #23). - onPause() disables the decoder and waits at most 300 ms for the audio thread instead of 2 s per executor, and Quad cells no longer block (fixes #32). - The frame that triggers decoder creation is passed to the new decoder. Tests: back-off delays and reset, the enable/disable gate, slot clamping. --- .../java/com/openipc/decoder/Decoder.java | 56 +++++++++----- .../com/openipc/decoder/DecoderRetryTest.java | 77 +++++++++++++++++++ 2 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 android/app/src/test/java/com/openipc/decoder/DecoderRetryTest.java 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 6b81f068..3762731f 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -326,21 +326,27 @@ private void loadSettings() { edit.apply(); } - mActive = 0; - quadEnabled = false; + // Restore the last camera and mode (#22); both are saved but were never read back + mActive = clampSlot(pref.getInt("active", 0)); + quadEnabled = pref.getBoolean("quad_enabled", false); for (int i = 0; i < CAM_COUNT; i++) { mHosts[i] = pref.getString("host_" + i, DEFAULT_URL); } - applyActiveCamera(); Intent intent = getIntent(); String link = intent.getStringExtra(Intent.EXTRA_TEXT); if (link != null) { Log.d(TAG, "Link: " + Uri.parse(link).getHost()); mHosts[mActive] = sanitizeUrl(link); - mHost = mHosts[mActive]; + // A shared link is meant to be watched: show it in single view + quadEnabled = false; intent.removeExtra(Intent.EXTRA_TEXT); } + applyActiveCamera(); + } + + static int clampSlot(int slot) { + return slot >= 0 && slot < CAM_COUNT ? slot : 0; } static String sanitizeUrl(String url) { @@ -464,10 +470,7 @@ private void startQuad() { closeDecoder(); closeAudio(); nalQueue.clear(); - if (executor != null) { - executor.shutdownNow(); - executor = null; - } + stopExecutor(); mSurface.setVisibility(View.GONE); quadContainer.setVisibility(View.VISIBLE); @@ -676,6 +679,9 @@ private void startListener() { listenerGen++; final int gen = listenerGen; + // The previous pool's threads exit on the generation check; shut it down so its + // idle threads do not leak on every camera switch (#23) + stopExecutor(); executor = Executors.newFixedThreadPool(2); // Video decode thread @@ -685,9 +691,12 @@ private void startListener() { try { Frame buffer = nalQueue.poll(5, TimeUnit.MILLISECONDS); if (buffer != null) { - boolean needCreate = videoDecoder.decodeFrame(buffer, codecH265, 0); + if (videoDecoder.decodeFrame(buffer, codecH265, 0)) { + createDecoder(); + // The frame that triggered creation (often the SPS) goes in too + videoDecoder.decodeFrame(buffer, codecH265, 0); + } framePool.recycle(buffer); - if (needCreate) createDecoder(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -725,23 +734,30 @@ protected void onPause() { if (rtspClient != null) { rtspClient.stop(); } - if (executor != null) { - executor.shutdownNow(); + // Disabled first: the decode thread can no longer create a decoder after this + videoDecoder.setEnabled(false); + // Short, bounded wait so the audio thread is out of AudioTrack before it is released + // (#32: this used to block the UI thread for seconds) + ExecutorService ex = executor; + stopExecutor(); + if (ex != null) { try { - if (!executor.awaitTermination(2, TimeUnit.SECONDS)) { - executor.shutdownNow(); - } + ex.awaitTermination(300, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { - executor.shutdownNow(); Thread.currentThread().interrupt(); } - executor = null; } - closeDecoder(); closeAudio(); nalQueue.clear(); } + private void stopExecutor() { + if (executor != null) { + executor.shutdownNow(); + executor = null; + } + } + @Override protected void onDestroy() { super.onDestroy(); @@ -753,6 +769,7 @@ protected void onResume() { super.onResume(); loadSettings(); + videoDecoder.setEnabled(true); if (quadEnabled) { mSurface.setVisibility(View.GONE); quadContainer.setVisibility(View.VISIBLE); @@ -773,6 +790,9 @@ protected void onResume() { getSharedPreferences("settings", MODE_PRIVATE).edit() .putBoolean("quad_enabled", true).apply(); } else { + // The views may still be in Quad layout from before the pause + quadContainer.setVisibility(View.GONE); + mSurface.setVisibility(View.VISIBLE); boolean configured = mHost != null && !mHost.isEmpty() && !mHost.equals(DEFAULT_URL); if (configured) { rtspClient.configure(mHost, mUserAgent); diff --git a/android/app/src/test/java/com/openipc/decoder/DecoderRetryTest.java b/android/app/src/test/java/com/openipc/decoder/DecoderRetryTest.java new file mode 100644 index 00000000..75b16ffa --- /dev/null +++ b/android/app/src/test/java/com/openipc/decoder/DecoderRetryTest.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * DecoderRetryTest.java — decoder re-creation back-off and enable/disable gate + * + */ + +package com.openipc.decoder; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class DecoderRetryTest { + @Test + public void delaysGrowAndAreCapped() { + assertEquals(0, DecoderRetry.delayMs(0)); + assertEquals(500, DecoderRetry.delayMs(1)); + assertEquals(1000, DecoderRetry.delayMs(2)); + assertEquals(2000, DecoderRetry.delayMs(3)); + assertEquals(8000, DecoderRetry.delayMs(5)); + assertEquals(10_000, DecoderRetry.delayMs(6)); + assertEquals(10_000, DecoderRetry.delayMs(100)); + } + + @Test + public void failureBlocksUntilTheDelayPasses() { + // #21: a failure is not permanent + DecoderRetry r = new DecoderRetry(); + assertTrue(r.mayCreate(1000)); + r.onFailure(1000); + assertFalse(r.mayCreate(1499)); + assertTrue(r.mayCreate(1500)); + r.onFailure(1500); + assertFalse(r.mayCreate(2499)); + assertTrue(r.mayCreate(2500)); + assertEquals(2, r.failures()); + } + + @Test + public void successResetsTheBackOff() { + DecoderRetry r = new DecoderRetry(); + r.onFailure(0); + r.onFailure(0); + r.onSuccess(); + assertEquals(0, r.failures()); + assertTrue(r.mayCreate(0)); + } + + private static Frame sps() { + byte[] d = {0, 0, 0, 1, 0x67, 0x42}; + return new Frame(d, d.length); + } + + @Test + public void disabledManagerNeverAsksForADecoder() { + // #32: after a pause no decoder may be created by a late decode thread + MediaCodecManager m = new MediaCodecManager(null); + assertTrue("no decoder yet: create one", m.decodeFrame(sps(), false, 0)); + m.setEnabled(false); + assertFalse(m.decodeFrame(sps(), false, 0)); + m.createDecoder(false); // must be a no-op while disabled + assertFalse(m.decodeFrame(sps(), false, 0)); + m.setEnabled(true); + assertTrue(m.decodeFrame(sps(), false, 0)); + } + + @Test + public void savedSlotIsClamped() { + // #22: a stored slot outside 0..3 falls back to the first camera + assertEquals(2, Decoder.clampSlot(2)); + assertEquals(0, Decoder.clampSlot(-1)); + assertEquals(0, Decoder.clampSlot(4)); + } +} From 31b78e32bb36c0e8793953e6b87575f3ca3a17d4 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 12:40:31 +0000 Subject: [PATCH 04/13] fix: address review findings for decoder lifecycle - setEnabled(false) takes the decoder under the lock and releases it on a short-lived thread, so MediaCodec stop()/release() never run on the UI thread. - A new decoder waits for the complete parameter set (SPS+PPS for H.264, VPS+SPS+PPS for H.265) before it gets other frames. - Each decoder creation belongs to a lifecycle generation: one cancelled by a pause neither installs a decoder nor records a failure that would delay the next start. - AudioManager.setEnabled(false) on pause: an audio worker still running cannot create or publish an AudioTrack or an AAC decoder afterwards; onPause no longer waits for it at all. - A shared link is saved (slot URL and quad_enabled=false), so the next pause/resume does not jump back to Quad and lose it. --- .../com/openipc/decoder/AudioManager.java | 30 +++++++++-- .../java/com/openipc/decoder/Decoder.java | 22 ++++---- .../openipc/decoder/MediaCodecManager.java | 50 +++++++++++++++---- 3 files changed, 77 insertions(+), 25 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/AudioManager.java b/android/app/src/main/java/com/openipc/decoder/AudioManager.java index a725e184..7ae496f9 100644 --- a/android/app/src/main/java/com/openipc/decoder/AudioManager.java +++ b/android/app/src/main/java/com/openipc/decoder/AudioManager.java @@ -67,6 +67,15 @@ class AudioManager { private final BlockingQueue aacQueue = new ArrayBlockingQueue<>(32); private volatile AudioConfig config = new AudioConfig(); + /** While false no AudioTrack or AAC decoder is created (paused activity). */ + private volatile boolean enabled = true; + private final Object trackLock = new Object(); + + /** Disabling also closes audio; a worker still running cannot start playback again. */ + void setEnabled(boolean enabled) { + this.enabled = enabled; + if (!enabled) closeAudio(); + } private String lastUnsupported; void shutdown() { @@ -126,7 +135,10 @@ void closeAudio() { aacRunning = false; aacQueue.clear(); pcmQueue.clear(); - AudioTrack track = audioTrackRef.getAndSet(null); + AudioTrack track; + synchronized (trackLock) { + track = audioTrackRef.getAndSet(null); + } if (track != null) { Log.i(TAG, "Close audio decoder"); try { @@ -229,6 +241,7 @@ private void playAudio(Frame data) { } private AudioTrack createPcmAudio(int sample, int channels) { + if (!enabled) return null; int mask = channels == 2 ? AudioFormat.CHANNEL_OUT_STEREO : AudioFormat.CHANNEL_OUT_MONO; Log.d(TAG, "Create PCM audio (" + sample + "hz, " + channels + "ch)"); @@ -268,7 +281,16 @@ private AudioTrack createPcmAudio(int sample, int channels) { return null; } - AudioTrack old = audioTrackRef.getAndSet(track); + AudioTrack old; + synchronized (trackLock) { + // Paused while the track was being created: do not publish it + if (!enabled) { + try { track.stop(); } catch (Exception ignored) {} + track.release(); + return null; + } + old = audioTrackRef.getAndSet(track); + } if (old != null) { try { old.stop(); } catch (Exception ignored) {} old.release(); @@ -386,9 +408,9 @@ static byte[] buildAacConfig(int sampleRate, int channels) { } private void ensureAacDecoder(AudioConfig c, boolean adts) { - if (aacDecoder != null || audioFailed) return; + if (aacDecoder != null || audioFailed || !enabled) return; synchronized (aacDecoderLock) { - if (aacDecoder != null || audioFailed) return; + if (aacDecoder != null || audioFailed || !enabled) return; Log.d(TAG, "Creating AAC decoder (" + c.sampleRate + "hz, " + c.channels + "ch, " + (adts ? "ADTS" : "raw") + ")"); 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 3762731f..b7f38cc0 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -338,8 +338,13 @@ private void loadSettings() { if (link != null) { Log.d(TAG, "Link: " + Uri.parse(link).getHost()); mHosts[mActive] = sanitizeUrl(link); - // A shared link is meant to be watched: show it in single view + // A shared link is meant to be watched: single view, and saved so that it is + // still there after the next pause/resume quadEnabled = false; + pref.edit() + .putString("host_" + mActive, mHosts[mActive]) + .putBoolean("quad_enabled", false) + .apply(); intent.removeExtra(Intent.EXTRA_TEXT); } applyActiveCamera(); @@ -736,18 +741,10 @@ protected void onPause() { } // Disabled first: the decode thread can no longer create a decoder after this videoDecoder.setEnabled(false); - // Short, bounded wait so the audio thread is out of AudioTrack before it is released - // (#32: this used to block the UI thread for seconds) - ExecutorService ex = executor; + // Same for audio: a worker still running cannot start playback again. Nothing to wait + // for on the UI thread (#32: this used to block it for seconds) + audioManager.setEnabled(false); stopExecutor(); - if (ex != null) { - try { - ex.awaitTermination(300, TimeUnit.MILLISECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - closeAudio(); nalQueue.clear(); } @@ -770,6 +767,7 @@ protected void onResume() { loadSettings(); videoDecoder.setEnabled(true); + audioManager.setEnabled(true); if (quadEnabled) { mSurface.setVisibility(View.GONE); quadContainer.setVisibility(View.VISIBLE); diff --git a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java index 1db09d22..330ac553 100644 --- a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java @@ -30,8 +30,12 @@ class MediaCodecManager { private final DecoderRetry retry = new DecoderRetry(); /** While false no decoder is created, so none can appear after a pause or stop. */ private volatile boolean enabled = true; - /** A fresh decoder skips input until the first parameter set (SPS/VPS/PPS). */ + /** Parameter sets seen since the decoder was created (bit per NAL type). */ + private int configSeen; + /** A fresh decoder skips non-config input until all parameter sets arrived. */ private boolean awaitingConfig; + /** Bumped by setEnabled(); a creation started in an older generation is discarded. */ + private int lifecycle; private final MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); // Surface set by the owning activity @@ -55,8 +59,24 @@ void setSurface(Surface s) { /** Enable or disable decoding; disabling also releases the decoder. */ void setEnabled(boolean enabled) { - this.enabled = enabled; - if (!enabled) closeDecoder(); + MediaCodec codec; + synchronized (decoderLock) { + this.enabled = enabled; + lifecycle++; + if (enabled) return; + retry.onSuccess(); + codec = decoder; + decoder = null; + } + // stop()/release() can take a while: never on the caller's (UI) thread + if (codec != null) releaseAsync(codec); + } + + private static void releaseAsync(MediaCodec codec) { + new Thread(() -> { + try { codec.stop(); } catch (Exception ignored) {} + try { codec.release(); } catch (Exception ignored) {} + }, "decoder-release").start(); } /** Release the decoder on purpose (codec switch, camera switch): no back-off. */ @@ -81,8 +101,10 @@ private static long nowMs() { } void createDecoder(boolean codecH265) { + final int gen; synchronized (decoderLock) { if (decoder != null || !enabled || !retry.mayCreate(nowMs())) return; + gen = lifecycle; } Surface s = surface; @@ -109,20 +131,24 @@ void createDecoder(boolean codecH265) { throw e; } } catch (Exception e) { - retry.onFailure(nowMs()); + synchronized (decoderLock) { + // A creation cancelled by a pause must not delay the next start + if (gen != lifecycle || !enabled) return; + retry.onFailure(nowMs()); + } Log.e(TAG, "Cannot setup decoder (attempt " + retry.failures() + "): " + e.getMessage()); return; } synchronized (decoderLock) { // Disabled meanwhile (pause/stop) or another thread was faster - if (decoder != null || !enabled) { - try { local.stop(); } catch (Exception ignored) {} - local.release(); + if (decoder != null || !enabled || gen != lifecycle) { + releaseAsync(local); return; } decoder = local; awaitingConfig = true; + configSeen = 0; } } @@ -151,10 +177,16 @@ boolean decodeFrame(Frame buffer, boolean codecH265, long watchdogRef) { if (codec == null) { needCreate = enabled && retry.mayCreate(nowMs()); } else if (awaitingConfig && !isConfigNal) { - // P-frames without parameter sets only produce errors in a new decoder + // Frames before the full parameter set only produce errors in a new decoder return false; } else { - awaitingConfig = false; + if (isConfigNal) { + configSeen |= 1 << fragment; + int needed = codecH265 + ? (1 << H265_NAL_VPS) | (1 << H265_NAL_SPS) | (1 << H265_NAL_PPS) + : (1 << H264_NAL_SPS) | (1 << H264_NAL_PPS); + if ((configSeen & needed) == needed) awaitingConfig = false; + } try { int inputBufferId = codec.dequeueInputBuffer(5_000); if (inputBufferId >= 0) { From 7797da900260bdbe81e2a7ae601d626e9619ce20 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 12:54:19 +0000 Subject: [PATCH 05/13] fix: setEnabled(false) never waits for the decoder lock Disabling only sets the flags; the decoder is detached under decoderLock and released on the decoder-release thread, so the UI thread does not wait even for the few milliseconds a decode step holds the lock. While disabled, decodeFrame() feeds nothing. --- .../openipc/decoder/MediaCodecManager.java | 48 ++++++++++++------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java index 330ac553..33fda394 100644 --- a/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java @@ -35,7 +35,7 @@ class MediaCodecManager { /** A fresh decoder skips non-config input until all parameter sets arrived. */ private boolean awaitingConfig; /** Bumped by setEnabled(); a creation started in an older generation is discarded. */ - private int lifecycle; + private volatile int lifecycle; private final MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); // Surface set by the owning activity @@ -58,25 +58,34 @@ void setSurface(Surface s) { } /** Enable or disable decoding; disabling also releases the decoder. */ + /** + * Enable or disable decoding. Never blocks the caller (the UI thread): disabling only + * sets flags; the decoder is detached and released on a short-lived thread, which is + * the only one that waits for decoderLock. + */ void setEnabled(boolean enabled) { - MediaCodec codec; - synchronized (decoderLock) { - this.enabled = enabled; - lifecycle++; - if (enabled) return; - retry.onSuccess(); - codec = decoder; - decoder = null; - } - // stop()/release() can take a while: never on the caller's (UI) thread - if (codec != null) releaseAsync(codec); + this.enabled = enabled; + lifecycle++; + if (enabled) return; + final MediaCodec target = decoder; + if (target == null) return; + new Thread(() -> { + synchronized (decoderLock) { + if (decoder != target) return; // already replaced or released + decoder = null; + retry.onSuccess(); + } + releaseNow(target); + }, "decoder-release").start(); + } + + private static void releaseNow(MediaCodec codec) { + try { codec.stop(); } catch (Exception ignored) {} + try { codec.release(); } catch (Exception ignored) {} } private static void releaseAsync(MediaCodec codec) { - new Thread(() -> { - try { codec.stop(); } catch (Exception ignored) {} - try { codec.release(); } catch (Exception ignored) {} - }, "decoder-release").start(); + new Thread(() -> releaseNow(codec), "decoder-release").start(); } /** Release the decoder on purpose (codec switch, camera switch): no back-off. */ @@ -174,8 +183,11 @@ boolean decodeFrame(Frame buffer, boolean codecH265, long watchdogRef) { boolean needCreate = false; synchronized (decoderLock) { MediaCodec codec = decoder; - if (codec == null) { - needCreate = enabled && retry.mayCreate(nowMs()); + if (!enabled) { + // Paused: the decoder is being released, feed nothing + return false; + } else if (codec == null) { + needCreate = retry.mayCreate(nowMs()); } else if (awaitingConfig && !isConfigNal) { // Frames before the full parameter set only produce errors in a new decoder return false; From 0a2e550751110c3029b4e6aab82d821200a43602 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 12:55:42 +0000 Subject: [PATCH 06/13] fix: wrap Quad cells in FrameLayouts (crash on resume in Quad mode) clearQuadViews() cast each cell's parent to FrameLayout, but the cells were added straight to a LinearLayout row. The code never ran before, because Quad mode was never restored on resume; with #22 fixed it would throw ClassCastException on the first resume in Quad mode. Each TextureView now sits in its own FrameLayout, and the black placeholder overlays are added to and found in that container. Before, findViewWithTag() was called on the TextureView itself, so the overlays never worked. --- .../src/main/java/com/openipc/decoder/Decoder.java | 14 ++++++++++---- 1 file changed, 10 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 b7f38cc0..0dc27109 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -231,7 +231,12 @@ public void onCodecChanged(boolean h265, AudioConfig audio) { quadViews[idx].setOnClickListener(cv -> menuManager.showMenu(menuAnchor, quadEnabled, mActive, mVersion, BuildConfig.GIT_HASH, mHosts)); - lr.addView(quadViews[idx], new LinearLayout.LayoutParams( + // Each cell is a FrameLayout so overlays can be stacked on the video + FrameLayout cell = new FrameLayout(this); + cell.addView(quadViews[idx], new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT)); + lr.addView(cell, new LinearLayout.LayoutParams( 0, LinearLayout.LayoutParams.MATCH_PARENT, 1)); } } @@ -536,7 +541,8 @@ private void clearQuadViews() { for (int i = 0; i < 4; i++) { TextureView t = quadViews[i]; if (t == null) continue; - View overlay = t.findViewWithTag("quad_overlay_" + i); + FrameLayout cell = (FrameLayout) t.getParent(); + View overlay = cell.findViewWithTag("quad_overlay_" + i); if (overlay == null) { overlay = new View(this); overlay.setTag("quad_overlay_" + i); @@ -544,7 +550,7 @@ private void clearQuadViews() { FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); overlay.setBackgroundColor(Color.BLACK); - ((FrameLayout) t.getParent()).addView(overlay); + cell.addView(overlay); } overlay.setVisibility(View.VISIBLE); overlay.bringToFront(); @@ -554,7 +560,7 @@ private void clearQuadViews() { private void removeQuadOverlay(int index) { if (index < 0 || index >= quadViews.length || quadViews[index] == null) return; TextureView t = quadViews[index]; - View overlay = t.findViewWithTag("quad_overlay_" + index); + View overlay = ((View) t.getParent()).findViewWithTag("quad_overlay_" + index); if (overlay != null) { overlay.setVisibility(View.GONE); } From 211f9d6f4e6a1f91cbe22c39cea0121992a36da8 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 13:36:54 +0000 Subject: [PATCH 07/13] fix: shared link to a running app, password in logcat, quieter stop Found on the device (#53): - A link shared while the app is already open was delivered through onNewIntent(), which was not overridden, so getIntent() still held the old intent and the link was ignored. onNewIntent() now calls setIntent(); onResume() applies it. - RTSP response lines and URL errors are redacted before logging: user:password@ and password=/pass=/pwd= parameters. XM cameras put the password in the URL path, and Content-Base echoed it to logcat (fixes #55). - A connection closed by stop() is no longer logged as "RTSP connection failed". - QuadCell logs when its surface is ready and the session starts (to diagnose a black Quad cell seen once on the device). --- .../java/com/openipc/decoder/Decoder.java | 7 +++++ .../java/com/openipc/decoder/QuadCell.java | 1 + .../java/com/openipc/decoder/RtspSession.java | 27 +++++++++++++++---- .../java/com/openipc/decoder/RtspUrlTest.java | 12 +++++++++ 4 files changed, 42 insertions(+), 5 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 0dc27109..85eb6a3e 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -767,6 +767,13 @@ protected void onDestroy() { audioManager.shutdown(); } + /** A link shared while the app is open arrives here; onResume() then applies it. */ + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + } + @Override protected void onResume() { super.onResume(); 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 7fe223d0..0253337b 100644 --- a/android/app/src/main/java/com/openipc/decoder/QuadCell.java +++ b/android/app/src/main/java/com/openipc/decoder/QuadCell.java @@ -159,6 +159,7 @@ private void startThreads() { } }); + Log.i(TAG, tag + ": surface ready, starting session"); // Video only: Quad mode has no audio (#12) session.configure(host, userAgent, false); session.start(); 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 2c5da084..78e7345a 100644 --- a/android/app/src/main/java/com/openipc/decoder/RtspSession.java +++ b/android/app/src/main/java/com/openipc/decoder/RtspSession.java @@ -138,6 +138,8 @@ private void networkLoop(int gen) { try { played = connect(gen); } catch (Exception e) { + // stop() closes the socket on purpose: not a connection failure + if (gen != generation) break; failures++; if (failures <= 3 || failures % 10 == 0) { Log.w(TAG, tag + ": RTSP connection failed (" + failures + "): " @@ -444,7 +446,7 @@ static Response readResponse(InputStream in) throws IOException { String status = readLine(in); if (status == null) throw new IOException("Server closed connection during handshake"); - Log.i(TAG, status); + Log.i(TAG, redact(status)); String[] parts = status.split(" ", 3); if (parts.length < 2 || !parts[0].startsWith("RTSP/")) { throw new IOException("Malformed RTSP response: " + status); @@ -458,7 +460,7 @@ static Response readResponse(InputStream in) throws IOException { String line; while ((line = readLine(in)) != null && !line.isEmpty()) { - Log.d(TAG, line); + Log.d(TAG, redact(line)); int colon = line.indexOf(':'); if (colon > 0) { String name = line.substring(0, colon).trim().toLowerCase(Locale.ROOT); @@ -489,6 +491,21 @@ static Response readResponse(InputStream in) throws IOException { return r; } + private static final java.util.regex.Pattern USERINFO = + java.util.regex.Pattern.compile("(?i)(rtsps?|https?)://[^/@\\s]*@"); + private static final java.util.regex.Pattern PASSWORD_PARAM = + java.util.regex.Pattern.compile("(?i)(pass(?:word)?|pwd)=[^_&;/?\\s]*"); + + /** + * Text safe for logs: "user:password@" and password parameters (some cameras put + * them in the URL path, e.g. user=admin_password=...) are replaced (#55). + */ + static String redact(String s) { + if (s == null) return null; + s = USERINFO.matcher(s).replaceAll("$1://***@"); + return PASSWORD_PARAM.matcher(s).replaceAll("$1=***"); + } + /** Read a body of unknown length: everything until 300 ms of silence, at most 64 KiB. */ private static String readUntilIdle(Socket s, InputStream in) throws IOException { int timeout = s.getSoTimeout(); @@ -606,7 +623,7 @@ static Url parse(String url) throws IOException { if (url == null) throw new IOException("Camera slot not configured"); url = url.trim(); int schemeEnd = url.indexOf("://"); - if (schemeEnd <= 0) throw new IOException("Invalid RTSP URL: " + url); + if (schemeEnd <= 0) throw new IOException("Invalid RTSP URL: " + redact(url)); String scheme = url.substring(0, schemeEnd).toLowerCase(Locale.ROOT); int authStart = schemeEnd + 3; int pathStart = authStart; @@ -634,7 +651,7 @@ static Url parse(String url) throws IOException { String hostPort = authority; if (hostPort.startsWith("[")) { int close = hostPort.indexOf(']'); - if (close < 0) throw new IOException("Invalid IPv6 host: " + url); + if (close < 0) throw new IOException("Invalid IPv6 host: " + redact(url)); u.host = hostPort.substring(1, close); hostPort = hostPort.substring(close + 1); if (hostPort.startsWith(":")) u.port = parsePort(hostPort.substring(1), url); @@ -657,7 +674,7 @@ private static int parsePort(String s, String url) throws IOException { int p = Integer.parseInt(s); if (p > 0 && p < 65536) return p; } catch (NumberFormatException ignored) {} - throw new IOException("Invalid port in URL: " + url); + throw new IOException("Invalid port in URL: " + redact(url)); } private static String percentDecode(String s) { diff --git a/android/app/src/test/java/com/openipc/decoder/RtspUrlTest.java b/android/app/src/test/java/com/openipc/decoder/RtspUrlTest.java index a17c94f2..161098d5 100644 --- a/android/app/src/test/java/com/openipc/decoder/RtspUrlTest.java +++ b/android/app/src/test/java/com/openipc/decoder/RtspUrlTest.java @@ -59,4 +59,16 @@ public void invalidUrls() { () -> RtspSession.Url.parse("rtsp://admin%0D%0AX-Injected:%201:pw@cam/s")); assertThrows(IOException.class, () -> RtspSession.Url.parse("rtsp://admin:p%0Aw@cam/s")); } + + @Test + public void credentialsAreRedactedForLogs() { + // #55 + assertEquals("Content-Base: rtsp://172.17.32.53/user=admin_password=***_channel=1_stream=0.sdp/", + RtspSession.redact("Content-Base: rtsp://172.17.32.53/user=admin_password=s3cr3t" + + "_channel=1_stream=0.sdp/")); + assertEquals("rtsp://***@cam:554/stream=0", + RtspSession.redact("rtsp://root:12345@cam:554/stream=0")); + assertEquals("http://***@cam/?pwd=***&x=1", RtspSession.redact("http://a:b@cam/?pwd=zz&x=1")); + assertEquals("RTSP/1.0 200 OK", RtspSession.redact("RTSP/1.0 200 OK")); + } } From a3dbd6cbfebc9d338c5d4a90d42059e86b9f2ca6 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 13:44:21 +0000 Subject: [PATCH 08/13] fix: apply a shared link when the app is already in the foreground On Android 10 the device delivered the SEND intent to the resumed activity through onNewIntent() without onPause()/onResume() (confirmed on the AG-216 box: no Link: log after the share). onNewIntent() now restarts the streams itself, which re-reads the settings and the new intent. onPause()/onResume() bodies moved into stopStreams()/startStreams(). --- .../main/java/com/openipc/decoder/Decoder.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 85eb6a3e..401dc180 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -732,8 +732,12 @@ private void startListener() { @Override protected void onPause() { super.onPause(); + mResumed = false; menuManager.onPauseDismissBrowser(); + stopStreams(); + } + private void stopStreams() { if (quadCells != null) { for (QuadCell cell : quadCells) { if (cell != null) cell.stop(); @@ -772,12 +776,24 @@ protected void onDestroy() { protected void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); + // Android 10+ may deliver it to the resumed activity without onPause()/onResume(): + // apply the shared link now + if (mResumed) { + stopStreams(); + startStreams(); + } } @Override protected void onResume() { super.onResume(); + mResumed = true; + startStreams(); + } + + private boolean mResumed; + private void startStreams() { loadSettings(); videoDecoder.setEnabled(true); audioManager.setEnabled(true); From cfbb492b36e1389d3c7ba2ca7e530f462d098cc3 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 13:53:29 +0000 Subject: [PATCH 09/13] fix: singleTop, so a link shared to the open app reaches onNewIntent() With the standard launch mode, a SEND intent with FLAG_ACTIVITY_NEW_TASK (as 'am start' and some apps send it) only brought the existing task to the front: nothing was delivered and the link was lost (seen on the AG-216 box). singleTop delivers it to the running activity through onNewIntent(); shares without NEW_TASK still create a new instance in the sharing app's task, as before. --- android/app/src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 23f0da2b..b494272d 100755 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -22,6 +22,7 @@ Date: Mon, 12 Oct 2026 14:03:26 +0000 Subject: [PATCH 10/13] fix: audio loop spinning at 100% CPU in Quad, double decoder start on H.265 Found on the device (#53): - startQuad() shut the executor down without bumping listenerGen. The audio loop kept running, and playPcmFrame() returned at once on every call with the interrupt flag set: one core at 100% for as long as Quad was shown. stopExecutor() now invalidates the generation first, and the loop also stops when interrupted. - NalAssembler treated the first H.265 packet as a switch from H.264 and closed the decoder that had just been created (two "Start video decoder" 0.6 s apart). The first packet now only sets the codec. --- .../src/main/java/com/openipc/decoder/Decoder.java | 5 ++++- .../main/java/com/openipc/decoder/NalAssembler.java | 12 ++++++++---- .../java/com/openipc/decoder/NalAssemblerTest.java | 7 +++++++ 3 files changed, 19 insertions(+), 5 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 401dc180..5002b529 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -721,7 +721,7 @@ private void startListener() { // Audio playback thread executor.execute(() -> { Thread.currentThread().setName("rtsp-audio"); - while (gen == listenerGen) { + while (gen == listenerGen && !Thread.currentThread().isInterrupted()) { audioManager.playPcmFrame(); } }); @@ -759,6 +759,9 @@ private void stopStreams() { } private void stopExecutor() { + // Invalidate the loops first: shutdownNow() alone left the audio loop spinning on + // InterruptedException at 100% CPU (seen on the device after entering Quad) + listenerGen++; if (executor != null) { executor.shutdownNow(); executor = null; diff --git a/android/app/src/main/java/com/openipc/decoder/NalAssembler.java b/android/app/src/main/java/com/openipc/decoder/NalAssembler.java index 652905ba..87839c9b 100644 --- a/android/app/src/main/java/com/openipc/decoder/NalAssembler.java +++ b/android/app/src/main/java/com/openipc/decoder/NalAssembler.java @@ -39,7 +39,8 @@ interface Sink { /** Skipping the rest of a fragmented NAL unit whose start was not seen (counted once). */ private boolean orphan; private int lastSeq = -1; - private boolean lastCodec; + /** -1 until the first packet, then 0 for H.264 and 1 for H.265. */ + private int lastCodec = -1; private int dropped; NalAssembler(int bufferSize, Runnable onCodecSwitch, FramePool framePool) { @@ -70,10 +71,13 @@ boolean assemble(byte[] buf, int offset, int length, boolean codecH265, Sink sin int start = offset + header; end += offset; - if (lastCodec != codecH265) { - lastCodec = codecH265; + int codec = codecH265 ? 1 : 0; + if (lastCodec != codec) { + // The first packet only sets the codec; a real switch releases the decoder + boolean switched = lastCodec >= 0; + lastCodec = codec; reset(); - onCodecSwitch.run(); + if (switched) onCodecSwitch.run(); } int seq = ((buf[offset + 2] & 0xFF) << 8) | (buf[offset + 3] & 0xFF); diff --git a/android/app/src/test/java/com/openipc/decoder/NalAssemblerTest.java b/android/app/src/test/java/com/openipc/decoder/NalAssemblerTest.java index 59777581..7de17ef0 100644 --- a/android/app/src/test/java/com/openipc/decoder/NalAssemblerTest.java +++ b/android/app/src/test/java/com/openipc/decoder/NalAssemblerTest.java @@ -197,6 +197,13 @@ public void oversizedNalIsDroppedNotTruncated() { assertEquals(1, nal.droppedCount()); } + @Test + public void firstPacketDoesNotCountAsACodecSwitch() { + // An H.265 stream used to close its first decoder on the first packet (seen on device) + feed(rtp(0x40, 0x01, 0x0C), true); + assertEquals(0, codecSwitches); + } + @Test public void codecSwitchResetsAndNotifies() { feed(rtp(0x67, 0x42), false); From 4660bbab83dae516574edd92154430de1e61cff4 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 14:08:36 +0000 Subject: [PATCH 11/13] fix: take the decode generation after stopping the old executor e8a7f2d made stopExecutor() bump listenerGen, but startListener() read the generation before calling it, so the new decode and audio threads exited at once and single view showed no video (caught by the device smoke test: 0 decoder starts). --- .../app/src/main/java/com/openipc/decoder/Decoder.java | 8 +++----- 1 file changed, 3 insertions(+), 5 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 5002b529..0786578b 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -687,12 +687,10 @@ private int dp(float dp) { // ====================== Video decode thread ====================== private void startListener() { - listenerGen++; - final int gen = listenerGen; - - // The previous pool's threads exit on the generation check; shut it down so its - // idle threads do not leak on every camera switch (#23) + // Stop the previous pool first (#23): stopExecutor() bumps listenerGen itself, so the + // generation for the new threads must be taken after it stopExecutor(); + final int gen = listenerGen; executor = Executors.newFixedThreadPool(2); // Video decode thread From c332a2c4357bd9a1309a67cc13c678bfab005aec Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 14:23:18 +0000 Subject: [PATCH 12/13] fix: force a layout pass if the Quad container was never laid out On the Android 10 test box the Quad container sometimes stayed at 0x0 after being made visible, with no traversal scheduled, so no cell got a surface and Quad stayed black until the next resume (seen after a start brought the task to the front). 500 ms after showing Quad, if it still has no size, force a layout pass and log it. --- .../java/com/openipc/decoder/Decoder.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 0786578b..06ffc3c7 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -484,6 +484,7 @@ private void startQuad() { mSurface.setVisibility(View.GONE); quadContainer.setVisibility(View.VISIBLE); + ensureQuadLaidOut(); removeClearOverlay(); quadCells = new QuadCell[4]; @@ -515,6 +516,26 @@ private void stopQuad() { mSurface.setVisibility(View.VISIBLE); } + /** + * Seen on an Android 10 TV box: after the window briefly lost visibility (a task brought + * to the front by another start), the Quad container stayed at 0x0 with no layout pass + * scheduled, so the cells never got a surface. Check once it should have been laid out + * and force a pass if not. + */ + private void ensureQuadLaidOut() { + quadContainer.postDelayed(() -> { + if (!quadEnabled || quadContainer.getWidth() > 0) return; + Log.w(TAG, "Quad layout did not happen, forcing a layout pass"); + View decor = getWindow().getDecorView(); + for (View v = quadContainer; v != null && v != decor; + v = v.getParent() instanceof View ? (View) v.getParent() : null) { + v.forceLayout(); + } + decor.requestLayout(); + decor.invalidate(); + }, 500); + } + /** Clear the video view so no stale frame lingers. */ private void clearVideo() { if (mSurface == null) return; @@ -801,6 +822,7 @@ private void startStreams() { if (quadEnabled) { mSurface.setVisibility(View.GONE); quadContainer.setVisibility(View.VISIBLE); + ensureQuadLaidOut(); if (quadCells != null) { for (QuadCell cell : quadCells) { if (cell != null) cell.stop(); From 5be43711421c825bf8ecc1bf28808efe219d271b Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 14:49:11 +0000 Subject: [PATCH 13/13] test: read the fake server's request list from a snapshot RtspSessionTest iterated server.requests (a synchronized list) while the server thread was still appending, which threw ConcurrentModification- Exception now and then (failed once in CI on #54). Tests now read a copy taken under the list's lock. --- .../com/openipc/decoder/RtspSessionTest.java | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/android/app/src/test/java/com/openipc/decoder/RtspSessionTest.java b/android/app/src/test/java/com/openipc/decoder/RtspSessionTest.java index 6f709785..3e8b359f 100644 --- a/android/app/src/test/java/com/openipc/decoder/RtspSessionTest.java +++ b/android/app/src/test/java/com/openipc/decoder/RtspSessionTest.java @@ -213,10 +213,15 @@ static byte[] packet(int channel, int pt) { List methods() { List m = new ArrayList<>(); + for (String r : snapshot()) m.add(r.substring(0, r.indexOf('\n'))); + return m; + } + + /** Copy of the requests: the server thread keeps adding while a test reads. */ + List snapshot() { synchronized (requests) { - for (String r : requests) m.add(r.substring(0, r.indexOf('\n'))); + return new ArrayList<>(requests); } - return m; } @Override @@ -294,10 +299,10 @@ public void handshakeWithAudio() throws Exception { assertEquals("SETUP " + base + "/trackID=1 RTSP/1.0", m.get(2)); assertEquals("PLAY " + base + " RTSP/1.0", m.get(3)); - String describe = server.requests.get(0); + String describe = server.snapshot().get(0); assertTrue(describe.contains("Authorization: Basic")); assertTrue(describe.contains("User-Agent: test")); - assertTrue(server.requests.get(3).contains("Session: ABC123")); + assertTrue(server.snapshot().get(3).contains("Session: ABC123")); assertTrue(rec.sdp.videoH265); assertEquals(AudioConfig.PCM, rec.sdp.audio.codec); @@ -451,7 +456,7 @@ public void keepaliveUsesGetParameter() throws Exception { assertTrue("keepalives sent: " + keepalives, keepalives >= 2); assertTrue(session.isActive()); assertEquals(1, server.connections.get()); - String ka = server.requests.stream().filter(x -> x.startsWith("GET_PARAMETER")) + String ka = server.snapshot().stream().filter(x -> x.startsWith("GET_PARAMETER")) .findFirst().get(); assertTrue(ka.contains("Session: ABC123")); } @@ -503,7 +508,7 @@ public void rejectedKeepaliveEndsTheSession() throws Exception { while (server.connections.get() < 2 && System.nanoTime() < deadline) Thread.sleep(20); assertTrue("reconnected after the keepalive was rejected", server.connections.get() >= 2); // Bounded: Basic, then one Digest retry per connection, no endless repetition - long perFirstConnection = server.requests.stream() + long perFirstConnection = server.snapshot().stream() .filter(x -> x.startsWith("GET_PARAMETER")).count(); assertTrue(perFirstConnection <= 2 * server.connections.get()); }