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")); + } }