Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 108 additions & 15 deletions android/app/src/main/java/com/openipc/decoder/Decoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ public class Decoder extends Activity
// Quad audio (#12): the cell whose sound plays, -1 for none
private int quadAudio = -1;
private final TextView[] quadAudioBadges = new TextView[CAM_COUNT];
/** Small text in each cell's corner: stats while playing, otherwise the status (#80, #82). */
private final TextView[] quadStats = new TextView[CAM_COUNT];
/** Samples (seconds) a cell may fall behind before the grid is relieved (#83). */
private static final int OVERLOAD_SAMPLES = 5;
/** After pausing a cell, time for the load to settle before the next decision. */
private static final long RELIEF_COOLDOWN_MS = 10_000;
private long lastReliefMs;
private volatile int quadAudioGen;
private ExecutorService quadAudioExecutor;

Expand Down Expand Up @@ -571,6 +578,7 @@ private void startQuad() {
}

private void stopQuad() {
quadContainer.removeCallbacks(quadStatsTick);
stopQuadAudio();
quadEnabled = false;
getSharedPreferences("settings", MODE_PRIVATE).edit()
Expand Down Expand Up @@ -621,6 +629,7 @@ private void buildQuadGrid(int size) {
quadContainer.removeAllViews();
Arrays.fill(quadViews, null);
Arrays.fill(quadAudioBadges, null);
Arrays.fill(quadStats, null);
for (int row = 0; row < size; row++) {
LinearLayout lr = new LinearLayout(this);
lr.setOrientation(LinearLayout.HORIZONTAL);
Expand Down Expand Up @@ -662,6 +671,18 @@ private void buildQuadGrid(int size) {
bp.setMargins(dp(4), dp(4), dp(4), dp(4));
cell.addView(badge, bp);
quadAudioBadges[idx] = badge;
TextView stats = new TextView(this);
stats.setTextColor(Color.WHITE);
stats.setTextSize(10);
stats.setBackgroundColor(0x80000000);
stats.setPadding(dp(4), dp(1), dp(4), dp(1));
stats.setVisibility(View.GONE);
FrameLayout.LayoutParams sp = new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM | Gravity.START);
sp.setMargins(dp(2), dp(2), dp(2), dp(2));
cell.addView(stats, sp);
quadStats[idx] = stats;
lr.addView(cell, new LinearLayout.LayoutParams(
0, LinearLayout.LayoutParams.MATCH_PARENT, 1));
}
Expand Down Expand Up @@ -723,6 +744,7 @@ private void createQuadCells() {
}
quadAudio = getSharedPreferences("settings", MODE_PRIVATE).getInt("quad_audio", -1);
startQuadAudio();
startStatsTick();
}

// ====================== Quad audio (#12) ======================
Expand Down Expand Up @@ -807,22 +829,92 @@ private void removeClearOverlay() {
}

private void clearQuadViews() {
for (int i = 0; i < CAM_COUNT; i++) {
SurfaceView t = quadViews[i];
if (t == null) continue;
FrameLayout cell = (FrameLayout) t.getParent();
View overlay = cell.findViewWithTag("quad_overlay_" + i);
if (overlay == null) {
overlay = new View(this);
overlay.setTag("quad_overlay_" + i);
overlay.setLayoutParams(new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT));
overlay.setBackgroundColor(Color.BLACK);
cell.addView(overlay);
for (int i = 0; i < CAM_COUNT; i++) showQuadOverlay(i);
}

/** Black overlay over a cell; its corner text stays visible above it. */
private void showQuadOverlay(int i) {
SurfaceView t = quadViews[i];
if (t == null) return;
FrameLayout cell = (FrameLayout) t.getParent();
View overlay = cell.findViewWithTag("quad_overlay_" + i);
if (overlay == null) {
overlay = new View(this);
overlay.setTag("quad_overlay_" + i);
overlay.setLayoutParams(new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT));
overlay.setBackgroundColor(Color.BLACK);
cell.addView(overlay);
}
overlay.setVisibility(View.VISIBLE);
overlay.bringToFront();
if (quadStats[i] != null) quadStats[i].bringToFront();
}

// ====================== Cell stats and decoder load (#80, #82, #83) ======================

private final Runnable quadStatsTick = new Runnable() {
private int ticks;

@Override
public void run() {
QuadCell[] cells = quadCells;
if (!quadEnabled || cells == null) return;
long now = System.nanoTime() / 1_000_000;
boolean log = ++ticks % 5 == 0;
for (int i = 0; i < cells.length; i++) {
QuadCell c = cells[i];
TextView v = quadStats[i];
if (c == null || v == null) continue;
String line = c.sample(now);
v.setText(line);
v.setVisibility(View.VISIBLE);
if (log) Log.i(TAG, "Quad-" + i + ": " + line);
}
overlay.setVisibility(View.VISIBLE);
overlay.bringToFront();
relieveDecoder(cells, now);
quadContainer.postDelayed(this, 1000);
}
};

private void startStatsTick() {
quadContainer.removeCallbacks(quadStatsTick);
quadContainer.postDelayed(quadStatsTick, 1000);
}

/**
* Load is judged by measurement, not by the declared budget: a cell falls behind when the
* decoder makes it lose frames (its queue overflows, or the decoder has no free input
* buffer). Frames lost on the network do not count: they lower decoded/received in the
* corner text but say nothing about the decoder. When a cell keeps falling behind, the
* highest-numbered playing cell is paused, then the load is given time to settle (#83).
*/
private void relieveDecoder(QuadCell[] cells, long now) {
boolean overloaded = false;
int active = 0;
int last = -1;
for (int i = 0; i < cells.length; i++) {
QuadCell c = cells[i];
if (c == null || c.isPaused()) continue;
active++;
last = i;
if (c.isSettled(now) && c.inFps() >= 5) {
Comment on lines +898 to +901

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Disconnected cells can be paused 📎 Requirement gap ☼ Reliability

relieveDecoder() increments active and sets last for every non-null, non-paused cell, even if
it is connecting, disconnected, or has no decoder; only overload measurement checks whether a cell
is settled. When a lower-numbered decoder is overloaded and a higher-numbered cell is not playing,
the non-playing cell is paused instead, leaving the decoder load unchanged, potentially pausing a
sole playing stream and delaying another relief attempt during the 10-second cooldown.
Agent Prompt
## Issue description
`relieveDecoder()` counts non-playing cells toward its active-cell threshold and can select them for overload relief. Pausing a connecting, disconnected, or decoder-less cell removes no decoder load and can delay a useful pause during the cooldown.

## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/Decoder.java[892-919]
- android/app/src/main/java/com/openipc/decoder/QuadCell.java[290-295]

## Recommended Fix
Count toward `active` and update `last` only for cells that are connected and currently have a decoder, adding an accessor on `QuadCell` if needed. Keep `last` as the highest-numbered eligible cell, skip relief when fewer than two cells are decoding, and retain the settled-duration requirement for detecting frame loss rather than using connecting or disconnected cells as pause candidates.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

boolean behind = c.lostFps() >= Math.max(2f, 0.1f * c.inFps());
c.behindSamples = behind ? c.behindSamples + 1 : 0;
if (c.behindSamples >= OVERLOAD_SAMPLES) overloaded = true;
Comment on lines +901 to +904

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Nal drops can trigger false overload 📎 Requirement gap ≡ Correctness

sample() sums queueFullDrops and inputDropped as lostFps, although both count dropped NAL
units, while relieveDecoder() compares that rate with RTP-marker-counted inFps without using the
decoded frame rate. Multi-slice frames, aggregated NAL packets, or parameter sets sent with
keyframes can inflate the measured loss past the max(2, 10% of inFps) threshold and pause a cell
even when decoded-to-received frame throughput remains healthy.
Agent Prompt
## Issue description
Overload detection compares dropped NAL units with received complete frames rather than measuring loss in a consistent unit. Multi-NAL access units can inflate the loss rate and cause a cell to be paused despite healthy decoded-to-received frame throughput.

## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/QuadCell.java[51-71]
- android/app/src/main/java/com/openipc/decoder/QuadCell.java[116-125]
- android/app/src/main/java/com/openipc/decoder/QuadCell.java[255-287]
- android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java[310-320]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[901-904]

## Recommended Fix
Normalize both sides of the overload comparison to the same unit. Prefer tracking decoder-caused loss per access unit: mark the current frame when any of its NALs is dropped, increment a frame-loss counter only once at its marker bit or new access-unit boundary, and compare that rate with received frames. Alternatively, base relief on decoded versus marker-bit received frame rates over the configured sample window, or compare NAL drops with received NAL units. Add a pause-threshold test covering aggregated NAL packets.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

} else {
c.behindSamples = 0;
}
}
if (!overloaded || active < 2 || now - lastReliefMs < RELIEF_COOLDOWN_MS) return;
lastReliefMs = now;
String reason = "Paused: decoder overloaded";
Log.w(TAG, "Decoder overloaded with " + active + " streams: pausing Quad-" + last);
cells[last].pause(reason);
showQuadOverlay(last);
if (quadStats[last] != null) quadStats[last].setText(reason);
Comment on lines +913 to +915

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

7. Freed decoder slot restores the overload 🐞 Bug ≡ Correctness

cells[last].pause() stops the cell, and setEnabled(false) calls releaseSlot(), which returns
its InstanceBudget slot. A different cell that is waiting on Decoder limit keeps retrying
tryAcquire (back-off of at most 10 s), takes the slot and starts decoding. The decoder load is
back where it was, and after the cooldown another cell is paused, so cells drop one by one while the
total load stays the same.
Agent Prompt
## Issue description
When a cell is paused for decoder overload, its instance slot is freed and a cell waiting on the instance limit immediately takes it, so the load is not reduced.

## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/Decoder.java[892-919]

## Recommended Fix
When overload is detected, first pause (with the same reason) every cell currently blocked by the decoder limit, or mark them so they stop retrying. Only then pause the highest-numbered decoding cell. Another option: once an overload pause has happened, keep a grid-level cap on the number of decoders so waiting cells cannot start.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

for (QuadCell c : cells) {
if (c != null) c.behindSamples = 0;
}
}

Expand Down Expand Up @@ -1058,6 +1150,7 @@ protected void onPause() {
menuManager.onPauseDismissBrowser();
// The UDP view is not a saved mode: after a resume the slot or grid comes back
leaveUdpView();
quadContainer.removeCallbacks(quadStatsTick);
stopStreams();
}

Expand Down
108 changes: 108 additions & 0 deletions android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@
package com.openipc.decoder;

import android.media.MediaCodec;
import android.media.MediaCodecInfo;
import android.media.MediaFormat;
import android.os.Build;
import android.util.Log;
import android.view.Surface;

import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.Map;

class MediaCodecManager {
private static final String TAG = "OpenIPCDecoder";
Expand Down Expand Up @@ -44,10 +48,92 @@ class MediaCodecManager {
// Surface set by the owning activity
private volatile Surface surface;

/** Frames rendered so far (#82): with the frames received this gives the real decode rate. */
private volatile int framesOut;
/** NAL units not fed because the decoder had no free input buffer: it is not keeping up. */
private volatile int inputDropped;
private volatile int outWidth;
private volatile int outHeight;
/** MIME type whose instance slot this manager holds while it has a decoder (#83). */
private String heldMime;

interface Listener {
void onResolutionChanged(int width, int height);
void onDecoderStarted();
void onDecoderFailed();
/** No decoder was created: {@code limit} decoders of {@code mime} already exist (#83). */
default void onDecoderLimit(String mime, int limit) {}
}

int framesOut() { return framesOut; }
int inputDropped() { return inputDropped; }
int outWidth() { return outWidth; }
int outHeight() { return outHeight; }
boolean hasDecoder() { return decoder != null; }

/**
* Decoders alive in this process per MIME type, against the declared limit of the codec
* (MediaCodecInfo.CodecCapabilities#getMaxSupportedInstances, Android 6+). The limit is
* learnt from the first decoder of a type, so it is the codec that createDecoderByType
* really picks (#83).
*/
static final class InstanceBudget {
private static final Map<String, Integer> used = new HashMap<>();
private static final Map<String, Integer> limit = new HashMap<>();

static synchronized boolean tryAcquire(String mime) {
int u = used.containsKey(mime) ? used.get(mime) : 0;
Integer max = limit.get(mime);
if (max != null && u >= max) return false;
used.put(mime, u + 1);
return true;
}

static synchronized void release(String mime) {
int u = used.containsKey(mime) ? used.get(mime) : 0;
if (u > 0) used.put(mime, u - 1);
}

static synchronized void learn(String mime, int max) {
if (max > 0 && !limit.containsKey(mime)) limit.put(mime, max);
}

/** Declared limit, or -1 while unknown. */
static synchronized int limit(String mime) {
Integer max = limit.get(mime);
return max != null ? max : -1;
}

static synchronized void reset() {
used.clear();
limit.clear();
}
}

/** Give the instance slot back; called wherever the decoder is detached (decoderLock held). */
private void releaseSlot() {
if (heldMime != null) {
InstanceBudget.release(heldMime);
heldMime = null;
}
}

/** Learn the codec's instance limit and log its declared throughput, once per type. */
private static void learnLimits(MediaCodec codec, String mime) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return;
if (InstanceBudget.limit(mime) >= 0) return;
try {
MediaCodecInfo.CodecCapabilities caps = codec.getCodecInfo().getCapabilitiesForType(mime);
int max = caps.getMaxSupportedInstances();
InstanceBudget.learn(mime, max);
MediaCodecInfo.VideoCapabilities video = caps.getVideoCapabilities();
String fps1080 = video != null && video.isSizeSupported(1920, 1080)
? String.valueOf(video.getSupportedFrameRatesFor(1920, 1080).getUpper()) : "?";
Log.i(TAG, "Decoder " + codec.getName() + " (" + mime + "): up to " + max
+ " instances, declared 1080p frame rate in total " + fps1080);
} catch (Exception e) {
Log.w(TAG, "Decoder limits unknown: " + e.getMessage());
}
}

private Listener listener;
Expand Down Expand Up @@ -76,6 +162,7 @@ void setEnabled(boolean enabled) {
synchronized (decoderLock) {
if (decoder != target) return; // already replaced or released
decoder = null;
releaseSlot();
Comment on lines 164 to +165

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

5. A freed slot can still hold a decoder 🐞 Bug ☼ Reliability

releaseSlot() decrements the shared instance count before the associated codec is stopped and
released. When another cell starts during that interval, it can acquire the supposedly free slot and
attempt to create a decoder while the device still has the maximum number alive.
Agent Prompt
## Issue description
Instance-budget slots become available before their native decoders are disposed, permitting concurrent creation above the declared limit.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java[157-175]
- android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java[182-195]
- android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java[255-264]
## Recommended Fix
Keep each reservation until its codec has actually been released, including asynchronous and cancelled-creation paths. Ensure each path returns its reservation exactly once after disposal.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

retry.onSuccess();
}
releaseNow(target);
Expand All @@ -98,6 +185,7 @@ void closeDecoder() {
MediaCodec codec = decoder;
if (codec == null) return;
decoder = null;
releaseSlot();
Log.i(TAG, "Close video decoder");
try {
codec.stop();
Expand Down Expand Up @@ -131,10 +219,21 @@ void createDecoder(boolean codecH265) {
// Must hold the largest NAL unit the assembler can deliver
format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, MAX_NAL_SIZE);

if (!InstanceBudget.tryAcquire(type)) {
int limit = InstanceBudget.limit(type);
synchronized (decoderLock) {
retry.onFailure(nowMs());
Comment on lines +222 to +225

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Two cells can exceed decoder limit 📎 Requirement gap ☼ Reliability

createDecoder() reserves an instance slot before learnLimits() discovers the codec's declared
maximum, so the initial reservations are allowed while the limit is unknown. If multiple cells start
the same codec concurrently, they can all create decoders before the first limit is published and
exceed that declared maximum.
Agent Prompt
## Issue description
The instance budget allows concurrent reservations while the codec limit is unknown, then learns the limit only after each decoder has already been created. Concurrent same-codec starts can therefore exceed the reported maximum.

## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java[84-105]
- android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java[121-136]
- android/app/src/main/java/com/openipc/decoder/MediaCodecManager.java[222-236]

## Recommended Fix
Coordinate first-codec creation and limit discovery with instance acquisition, or otherwise serialize unknown-limit startup so later same-codec requests wait for the learned limit before creating a decoder; release reservations on every failed or cancelled attempt.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}
Log.w(TAG, "Decoder limit reached: " + limit + " × " + type);
if (listener != null) listener.onDecoderLimit(type, limit);
return;
}

MediaCodec local;
try {
Log.i(TAG, "Start video decoder (" + type + ")");
local = MediaCodec.createDecoderByType(type);
learnLimits(local, type);
try {
local.configure(format, s, null, 0);
local.start();
Expand All @@ -144,6 +243,7 @@ void createDecoder(boolean codecH265) {
throw e;
}
} catch (Exception e) {
InstanceBudget.release(type);
synchronized (decoderLock) {
// A creation cancelled by a pause must not delay the next start
if (gen != lifecycle || !enabled) return;
Expand All @@ -156,10 +256,12 @@ void createDecoder(boolean codecH265) {
synchronized (decoderLock) {
// Disabled meanwhile (pause/stop) or another thread was faster
if (decoder != null || !enabled || gen != lifecycle) {
InstanceBudget.release(type);
releaseAsync(local);
return;
}
decoder = local;
heldMime = type;
awaitingConfig = true;
configSeen = 0;
}
Expand Down Expand Up @@ -213,6 +315,8 @@ boolean decodeFrame(Frame buffer, boolean codecH265, long watchdogRef) {
codec.queueInputBuffer(inputBufferId, 0,
buffer.length(), System.nanoTime() / 1000, flag);
}
} else {
inputDropped++;
}

MediaCodec.BufferInfo info = bufferInfo;
Expand All @@ -223,16 +327,20 @@ boolean decodeFrame(Frame buffer, boolean codecH265, long watchdogRef) {
MediaFormat format = codec.getOutputFormat();
int mWidth = format.getInteger(MediaFormat.KEY_WIDTH);
int mHeight = format.getInteger(MediaFormat.KEY_HEIGHT);
outWidth = mWidth;
outHeight = mHeight;
if (listener != null) {
listener.onResolutionChanged(mWidth, mHeight);
}
} else {
codec.releaseOutputBuffer(outputBufferId, true);
framesOut++;
retry.onSuccess(); // a decoded frame: the decoder works
}
}
} catch (Exception e) {
decoder = null;
releaseSlot();
retry.onFailure(nowMs());
Log.e(TAG, "Codec exception, retry in "
+ DecoderRetry.delayMs(retry.failures()) + " ms: " + e.getMessage());
Expand Down
Loading
Loading