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
18 changes: 13 additions & 5 deletions android/app/src/main/java/com/openipc/decoder/Decoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ public class Decoder extends Activity
static final int CAM_COUNT = 8;
private static final String DEFAULT_URL = "rtsp://root:12345@192.168.1.10:554/stream=0";
private final String[] mHosts = new String[CAM_COUNT];
/** RTP transport per slot (#67): TCP, UDP or Auto (UDP with fallback to TCP). */
private final Transport.Mode[] mTransports = new Transport.Mode[CAM_COUNT];
private int mActive;
private volatile String mHost;
private String mVersion;
Expand Down Expand Up @@ -299,7 +301,7 @@ public void onToggleQuad() {
stopQuad();
boolean configured = mHost != null && !mHost.isEmpty() && !mHost.equals(DEFAULT_URL);
if (configured) {
rtspClient.configure(mHost, mUserAgent);
rtspClient.configure(mHost, mUserAgent, mTransports[mActive]);
startListener();
rtspClient.start();
}
Expand Down Expand Up @@ -350,7 +352,7 @@ private boolean leaveUdpView() {
public void onExit() { finishAndRemoveTask(); }

@Override
public void onShowSettings() { menuManager.showUrlEditor(mActive, mHosts); }
public void onShowSettings() { menuManager.showUrlEditor(mActive, mHosts, mTransports); }

@Override
public void onWebUI() { menuManager.startBrowser(mHost); }
Expand Down Expand Up @@ -395,6 +397,7 @@ private void loadSettings() {
String url = credentials.open(stored);
mHosts[i] = url != null ? url : DEFAULT_URL;
mUnreadable[i] = url == null && stored != null ? stored : null;
mTransports[i] = Transport.Mode.parse(pref.getString("transport_" + i, null));
// Plain URLs from older versions are re-saved encrypted right away
if (credentials.needsMigration(stored)) {
if (migrate == null) migrate = pref.edit();
Expand All @@ -408,13 +411,17 @@ private void loadSettings() {
if (link != null) {
Log.d(TAG, "Link: " + Uri.parse(link).getHost());
mHosts[mActive] = sanitizeUrl(link);
// Optional, e.g. --es transport udp; a link without it keeps the slot's setting
String transport = intent.getStringExtra("transport");
if (transport != null) mTransports[mActive] = Transport.Mode.parse(transport);
// 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, credentials.sealForSlot(
mHosts[mActive], mUnreadable[mActive], DEFAULT_URL))
.putBoolean("quad_enabled", false)
.putString("transport_" + mActive, mTransports[mActive].name())
.apply();
intent.removeExtra(Intent.EXTRA_TEXT);
}
Expand Down Expand Up @@ -446,6 +453,7 @@ private void saveSettings() {
for (int i = 0; i < CAM_COUNT; i++) {
edit.putString("host_" + i,
credentials.sealForSlot(mHosts[i], mUnreadable[i], DEFAULT_URL));
edit.putString("transport_" + i, mTransports[i].name());
}
edit.apply();

Expand All @@ -466,7 +474,7 @@ private void saveSettings() {
return;
}

rtspClient.configure(mHost, mUserAgent);
rtspClient.configure(mHost, mUserAgent, mTransports[mActive]);
startListener();
rtspClient.start();
}
Expand Down Expand Up @@ -709,7 +717,7 @@ private void createQuadCells() {
if (quadViews[i] == null) continue;
String url = mHosts[i];
if (url == null || url.isEmpty() || url.equals(DEFAULT_URL)) continue;
quadCells[i] = new QuadCell(i, url, quadViews[i], framePool, mUserAgent,
quadCells[i] = new QuadCell(i, url, mTransports[i], quadViews[i], framePool, mUserAgent,
index -> runOnUiThread(() -> removeQuadOverlay(index)));
quadCells[i].start();
}
Expand Down Expand Up @@ -1140,7 +1148,7 @@ private void startStreams() {
mSurface.setVisibility(View.VISIBLE);
boolean configured = mHost != null && !mHost.isEmpty() && !mHost.equals(DEFAULT_URL);
if (configured) {
rtspClient.configure(mHost, mUserAgent);
rtspClient.configure(mHost, mUserAgent, mTransports[mActive]);
startListener();
rtspClient.start();
}
Expand Down
28 changes: 26 additions & 2 deletions android/app/src/main/java/com/openipc/decoder/MenuManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
import android.widget.HorizontalScrollView;
import android.widget.LinearLayout;
import android.widget.PopupWindow;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import android.widget.TextView;

class MenuManager {
Expand Down Expand Up @@ -342,7 +344,7 @@ private EditText createEdit(String title) {
return text;
}

void showUrlEditor(int mActive, String[] mHosts) {
void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports) {
AlertDialog.Builder builder = new AlertDialog.Builder(activity);
builder.setTitle("Camera " + (mActive + 1) + " URL");

Expand All @@ -351,12 +353,34 @@ void showUrlEditor(int mActive, String[] mHosts) {
input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
input.setSelectAllOnFocus(true);
input.setSelection(input.getText().length());
builder.setView(input);

// RTP transport of the slot (#67): Auto tries UDP and falls back to TCP
RadioGroup transport = new RadioGroup(activity);
transport.setOrientation(RadioGroup.HORIZONTAL);
Transport.Mode[] modes = Transport.Mode.values();
String[] labels = {"TCP", "UDP", "Auto"};
for (int i = 0; i < modes.length; i++) {
RadioButton b = new RadioButton(activity);
b.setId(View.generateViewId());
b.setText(labels[i]);
b.setTag(modes[i]);
transport.addView(b);
if (modes[i] == mTransports[mActive]) b.setChecked(true);
}

LinearLayout content = new LinearLayout(activity);
content.setOrientation(LinearLayout.VERTICAL);
content.setPadding(dp(16), dp(8), dp(16), 0);
content.addView(input);
content.addView(transport);
builder.setView(content);

final int slot = mActive;
builder.setPositiveButton("Save", (dialog, which) -> {
String url = input.getText().toString().trim();
mHosts[slot] = Decoder.sanitizeUrl(url);
View checked = transport.findViewById(transport.getCheckedRadioButtonId());
if (checked != null) mTransports[slot] = (Transport.Mode) checked.getTag();
if (listener != null) listener.onSaveSettings(); // triggers save + reconnect
});
builder.setNegativeButton("Cancel", (dialog, which) -> dialog.cancel());
Expand Down
7 changes: 5 additions & 2 deletions android/app/src/main/java/com/openipc/decoder/QuadCell.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class QuadCell implements RtspSession.Callback {

private final int index;
private final String host;
private final Transport.Mode transport;
private final SurfaceView view;
private final String tag;
private final FramePool cellFramePool;
Expand Down Expand Up @@ -66,9 +67,11 @@ void setAudioSink(AudioManager sink, int route) {

private final Listener listener;

QuadCell(int index, String host, SurfaceView view, FramePool pool, String userAgent, Listener listener) {
QuadCell(int index, String host, Transport.Mode transport, SurfaceView view, FramePool pool,
String userAgent, Listener listener) {
this.index = index;
this.host = host;
this.transport = transport;
this.view = view;
this.tag = "Quad-" + index;
this.cellFramePool = pool;
Expand Down Expand Up @@ -160,7 +163,7 @@ private void startThreads() {

Log.i(TAG, tag + ": surface ready, starting session");
// Audio is set up too, so switching the audible cell needs no reconnect (#12)
session.configure(host, userAgent, true);
session.configure(host, userAgent, true, transport);
session.start();
}

Expand Down
150 changes: 150 additions & 0 deletions android/app/src/main/java/com/openipc/decoder/RtpReorder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
*
* RtpReorder.java — puts RTP packets received over UDP back into sequence order
*
*/

package com.openipc.decoder;

/**
* UDP may reorder datagrams; the depacketizer expects them in sequence order and treats a
* gap as loss (#67). In-order packets pass straight through, so a clean network adds no
* delay. A packet that arrives early is held until the missing ones arrive, the hold time
* runs out, or too many are held; then the gap is skipped. Late duplicates are dropped.
* Single-threaded: called from the network thread only.
*/
class RtpReorder {
interface Sink {
void deliver(byte[] buf, int offset, int length);
}

/** Ring size; a jump of this many packets or more is not waited for. */
private static final int SLOTS = 128;
/** A sequence number this far behind is a restarted stream, not a late packet. */
private static final int RESTART_BEHIND = 1000;

private final int maxHeld;
private final long maxHoldNs;

private final byte[][] data = new byte[SLOTS][];
private final int[] length = new int[SLOTS];
private final long[] heldAtNs = new long[SLOTS];
private final boolean[] present = new boolean[SLOTS];
private int held;
private int expected = -1;
private long dropped;
private long skipped;

RtpReorder(int maxHeld, long maxHoldMs) {
this.maxHeld = Math.min(maxHeld, SLOTS - 1);
this.maxHoldNs = maxHoldMs * 1_000_000L;
}

void reset() {
for (int i = 0; i < SLOTS; i++) present[i] = false;
held = 0;
expected = -1;
}

/** Late or duplicate packets dropped so far. */
long dropped() { return dropped; }
/** Missing packets given up on so far. */
long skipped() { return skipped; }
int held() { return held; }

static int seq(byte[] buf, int offset) {
return ((buf[offset + 2] & 0xFF) << 8) | (buf[offset + 3] & 0xFF);
}

void push(byte[] buf, int offset, int len, long nowNs, Sink sink) {
if (len < 12) return;
int seq = seq(buf, offset);
if (expected < 0) {
expected = seq;
}
int d = (short) (seq - expected); // signed distance, wraps at 16 bits
if (d == 0) {
sink.deliver(buf, offset, len);
expected = (seq + 1) & 0xFFFF;
drain(sink);
} else if (d < 0) {
if (d > -RESTART_BEHIND) {
dropped++;
return;
}
restart(seq, buf, offset, len, sink);
} else if (d >= SLOTS) {
// Too far ahead to wait for: hand over what is held, then continue from here
flushAll(sink);
restart(seq, buf, offset, len, sink);
} else {
int i = seq & (SLOTS - 1);
if (present[i]) {
dropped++;
return;
}
if (data[i] == null || data[i].length < len) data[i] = new byte[Math.max(len, 2048)];
System.arraycopy(buf, offset, data[i], 0, len);
length[i] = len;
heldAtNs[i] = nowNs;
present[i] = true;
held++;
if (held > maxHeld) skipGap(sink);
}
}

/** Give up on a gap whose oldest held packet has waited too long. */
void poll(long nowNs, Sink sink) {
while (held > 0 && nowNs - oldestHeldNs() >= maxHoldNs) skipGap(sink);
}

/** Time until {@link #poll} has something to do, in ms; -1 when nothing is held. */
long msUntilDeadline(long nowNs) {
if (held == 0) return -1;
long ns = oldestHeldNs() + maxHoldNs - nowNs;
return ns <= 0 ? 0 : Math.max(1, ns / 1_000_000);
}

private long oldestHeldNs() {
long oldest = Long.MAX_VALUE;
for (int i = 0; i < SLOTS; i++) {
if (present[i] && heldAtNs[i] < oldest) oldest = heldAtNs[i];
}
return oldest;
}

private void restart(int seq, byte[] buf, int offset, int len, Sink sink) {
reset();
sink.deliver(buf, offset, len);
expected = (seq + 1) & 0xFFFF;
}

/** Deliver the held packets that now follow on. */
private void drain(Sink sink) {
while (held > 0) {
int i = expected & (SLOTS - 1);
if (!present[i]) return;
present[i] = false;
held--;
sink.deliver(data[i], 0, length[i]);
expected = (expected + 1) & 0xFFFF;
}
}

/** Skip to the first held packet. */
private void skipGap(Sink sink) {
for (int k = 1; k < SLOTS; k++) {
if (present[(expected + k) & (SLOTS - 1)]) {
skipped += k;
expected = (expected + k) & 0xFFFF;
drain(sink);
return;
}
}
}

private void flushAll(Sink sink) {
while (held > 0) skipGap(sink);
}
}
4 changes: 2 additions & 2 deletions android/app/src/main/java/com/openipc/decoder/RtspClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ interface Listener {
void setAudioManager(AudioManager am) { this.audioManager = am; }
void setAudioRoute(int route) { this.audioRoute = route; }

void configure(String host, String userAgent) {
session.configure(host, userAgent, true);
void configure(String host, String userAgent, Transport.Mode transport) {
session.configure(host, userAgent, true, transport);
}

boolean isActive() { return session.isActive(); }
Expand Down
Loading
Loading