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 @@
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 6b81f068..06ffc3c7 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));
}
}
@@ -326,21 +331,32 @@ 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: 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();
+ }
+
+ static int clampSlot(int slot) {
+ return slot >= 0 && slot < CAM_COUNT ? slot : 0;
}
static String sanitizeUrl(String url) {
@@ -464,13 +480,11 @@ private void startQuad() {
closeDecoder();
closeAudio();
nalQueue.clear();
- if (executor != null) {
- executor.shutdownNow();
- executor = null;
- }
+ stopExecutor();
mSurface.setVisibility(View.GONE);
quadContainer.setVisibility(View.VISIBLE);
+ ensureQuadLaidOut();
removeClearOverlay();
quadCells = new QuadCell[4];
@@ -502,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;
@@ -528,7 +562,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);
@@ -536,7 +571,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();
@@ -546,7 +581,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);
}
@@ -673,9 +708,10 @@ private int dp(float dp) {
// ====================== Video decode thread ======================
private void startListener() {
- listenerGen++;
+ // 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
@@ -685,9 +721,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();
@@ -701,7 +740,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();
}
});
@@ -712,8 +751,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();
@@ -725,21 +768,23 @@ protected void onPause() {
if (rtspClient != null) {
rtspClient.stop();
}
+ // Disabled first: the decode thread can no longer create a decoder after this
+ videoDecoder.setEnabled(false);
+ // 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();
+ nalQueue.clear();
+ }
+
+ 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();
- try {
- if (!executor.awaitTermination(2, TimeUnit.SECONDS)) {
- executor.shutdownNow();
- }
- } catch (InterruptedException e) {
- executor.shutdownNow();
- Thread.currentThread().interrupt();
- }
executor = null;
}
- closeDecoder();
- closeAudio();
- nalQueue.clear();
}
@Override
@@ -748,14 +793,36 @@ 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);
+ // 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);
if (quadEnabled) {
mSurface.setVisibility(View.GONE);
quadContainer.setVisibility(View.VISIBLE);
+ ensureQuadLaidOut();
if (quadCells != null) {
for (QuadCell cell : quadCells) {
if (cell != null) cell.stop();
@@ -773,6 +840,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/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..33fda394 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,16 @@ 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;
+ /** 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 volatile int lifecycle;
private final MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
// Surface set by the owning activity
@@ -48,10 +57,43 @@ void setSurface(Surface s) {
this.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) {
+ 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(() -> releaseNow(codec), "decoder-release").start();
+ }
+
+ /** 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 +102,18 @@ 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) {
+ final int gen;
synchronized (decoderLock) {
- if (decoder != null) return;
+ if (decoder != null || !enabled || !retry.mayCreate(nowMs())) return;
+ gen = lifecycle;
}
Surface s = surface;
@@ -93,21 +140,31 @@ void createDecoder(boolean codecH265) {
throw e;
}
} catch (Exception e) {
- Log.e(TAG, "Cannot setup decoder: " + e.getMessage());
- decoderFailed = true;
+ 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) {
- if (decoder != null) {
- local.release();
+ // Disabled meanwhile (pause/stop) or another thread was faster
+ if (decoder != null || !enabled || gen != lifecycle) {
+ releaseAsync(local);
return;
}
decoder = local;
+ awaitingConfig = true;
+ configSeen = 0;
}
}
- /** 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());
@@ -126,9 +183,22 @@ boolean decodeFrame(Frame buffer, boolean codecH265, long watchdogRef) {
boolean needCreate = false;
synchronized (decoderLock) {
MediaCodec codec = decoder;
- if (codec == null) {
- needCreate = !decoderFailed;
+ 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;
} else {
+ 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) {
@@ -154,12 +224,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();
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/main/java/com/openipc/decoder/QuadCell.java b/android/app/src/main/java/com/openipc/decoder/QuadCell.java
index 031e020e..0253337b 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) {
@@ -157,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();
@@ -168,24 +171,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 +193,6 @@ public void onSdp(Sdp sdp) {
lastUnknownPayload = -1;
videoPt = sdp.videoPt;
codecH265 = sdp.videoH265;
- initDecoder();
}
@Override
@@ -222,48 +213,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);
}
}
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/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));
+ }
+}
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);
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());
}
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"));
+ }
}