Skip to content

feat: RTSP over UDP unicast, with fallback to TCP (#67) - #70

Merged
compot-dev merged 3 commits into
v2from
feat/rtsp-udp
Oct 12, 2026
Merged

compot-dev merged 3 commits into
v2from
feat/rtsp-udp

Conversation

@compot-dev

Copy link
Copy Markdown
Collaborator

Closes #67. Stacked on #64 (8 slots), because the transport is a per-slot setting.

What changes

Every camera slot gets a transport: TCP (as before, the default), UDP, or Auto (UDP, falling back to TCP). It is set in the URL dialog (radio buttons under the URL) and can come with a shared link: --es transport udp|tcp|auto.

  • UdpReceiver binds an even RTP port and the RTCP port above it per track. Ports are always ephemeral (the fixed 5000/5002 were the bug of basic: track URLs, payload types and UDP ports are hard-coded #40). Every track is received on the network thread through one Selector, so onPacket is still called from a single thread, as over TCP. Only datagrams from the camera are accepted (the RTSP peer, or the reply's source). RTCP is read and discarded.
  • RtpReorder puts packets back in sequence order. In-order packets pass straight through, so a clean LAN gets no extra delay. A gap is waited for at most 40 ms or 64 packets, then skipped (NalAssembler drops the broken access unit as before). Late duplicates are dropped.
  • Transport parses the SETUP reply, and the reply decides what is received. Majestic answers RTP/AVP/UDP;unicast;client_port;server_port, XM RTP/AVP;unicast;mode=PLAY;source;client_port;server_port;ssrc. Both silently downgrade a multicast request to unicast (RTSP multicast and other RTSP transports (research: Majestic RTSP has no multicast) #69). A server answering with interleaved TCP is followed.
  • Over UDP a second thread reads the RTSP replies (keepalives). Closing a UDP session sends TEARDOWN, because the server otherwise keeps sending until the session times out, and XM cameras allow only one client.
  • Auto falls back to TCP in two cases: when the UDP SETUP is refused (a second SETUP in the same connection), or when no video datagram arrives within 3 s after PLAY (TEARDOWN, then reconnect). It stays on TCP until the slot is reconfigured. UDP never falls back.

Tests

  • TransportTest: Majestic, XM, interleaved and multicast replies; malformed values; settings values.
  • RtpReorderTest: in order, swapped, lost (hold time and held-count limit), duplicates, 16-bit wrap, jumps and restarts.
  • RtspSessionTest (scripted server, now with UDP): UDP handshake with video and audio over even/odd port pairs, Auto fallback on 461 and on silence, UDP without fallback, TEARDOWN on stop, datagrams from another address ignored.

Device test

To do on the AG-216 with the CI build, using the methodology of #53, on group A cameras (Majestic H.264 and H.265 + Opus, XM H.264):

  • UDP single view: video + audio, latency compared with TCP
  • UDP in the 3×3 grid (8 sessions, 16 ports)
  • Auto with UDP blocked: falls back to TCP
  • Slot change, pause/resume: no leaked sockets (/proc/<pid>/net/udp*), TEARDOWN sent
  • XM (one client only): reconnect and slot change still work over UDP

@compot-dev

Copy link
Copy Markdown
Collaborator Author

/review

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (1) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Multicast replies stall UDP playback ✓ Resolved
Description
RtspSession.connect ignores the parsed multicast flag and continues with ordinary UDP sockets when
a server selects multicast. Because no multicast group is joined and Auto is not immediately given a
rejected transport, explicit UDP remains stalled and Auto delays recovery until its silence timeout.
Code

android/app/src/main/java/com/openipc/decoder/RtspSession.java[R291-294]

+            if (rx != null && reply.tcp) {
+                // Asked for UDP, answered with interleaved TCP: take what the server chose
+                Log.i(TAG, tag + ": server chose TCP instead of UDP");
+                rx = dropUdp(rx);
Evidence
The session only drops UDP when the reply is TCP, while the parser records multicast metadata that
the receiver never applies. This misinterprets a multicast negotiation and violates the requirement
to handle multicast negotiation safely.

Handle RTSP multicast negotiation if implemented
android/app/src/main/java/com/openipc/decoder/RtspSession.java[290-295]
android/app/src/main/java/com/openipc/decoder/Transport.java[61-72]
android/app/src/main/java/com/openipc/decoder/UdpReceiver.java[83-106]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A multicast SETUP response is parsed but treated as ordinary UDP, even though the receiver never joins the advertised group or uses its destination and ports.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[290-295]
- android/app/src/main/java/com/openipc/decoder/Transport.java[61-72]
- android/app/src/main/java/com/openipc/decoder/UdpReceiver.java[83-106]
## Recommended Fix
Either implement multicast group membership with the negotiated destination, ports, TTL, and required Android multicast lock, or explicitly reject multicast responses and route Auto to TCP instead of starting the unicast receiver.

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


2. Malformed UDP replies stall playback 📎 Requirement gap ☼ Reliability
Description
RtspSession.connect accepts a non-TCP SETUP reply without validating that its negotiated client
ports and UDP transport are usable or match the locally bound sockets. A missing or malformed
Transport header therefore leaves UDP marked active while packets cannot arrive on the negotiated
path, delaying Auto recovery until its silence timeout.
Code

android/app/src/main/java/com/openipc/decoder/RtspSession.java[R288-291]

+            session = sessionHeader.split(";")[0].trim();
         long keepaliveMs = keepaliveInterval(sessionHeader);
+            Transport reply = Transport.parse(r.header("transport"));
+            if (rx != null && reply.tcp) {
Evidence
The code sends a request using locally allocated ports, then only checks whether the response is
TCP; parsed client and server ports are never validated or applied to the receiver. This can accept
an unusable UDP negotiation and leave playback waiting for datagrams that cannot reach the bound
sockets.

Receive UDP RTP safely with ephemeral ports
android/app/src/main/java/com/openipc/decoder/RtspSession.java[273-295]
android/app/src/main/java/com/openipc/decoder/Transport.java[48-81]
android/app/src/main/java/com/openipc/decoder/UdpReceiver.java[83-106]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The UDP SETUP response is parsed but its negotiated transport and port values are not validated against the receiver sockets or required UDP unicast fields.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[273-295]
- android/app/src/main/java/com/openipc/decoder/Transport.java[48-81]
- android/app/src/main/java/com/openipc/decoder/UdpReceiver.java[83-106]
## Recommended Fix
Require a valid UDP unicast response with valid client and server port pairs, verify the returned client ports match the allocated sockets, and treat invalid or mismatched replies as UDP setup failure so Auto can tear down and reconnect over TCP.

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



Remediation recommended

3. Mixed transports leave audio silent ✓ Resolved
Description
connect() checks the video SETUP reply for a TCP choice but checks the audio SETUP reply only for
success, leaving rx active when audio is assigned interleaved TCP. If a server chooses UDP video
and TCP audio, receiveUdp() handles the session while its control reader skips interleaved audio
frames instead of passing them to onPacket.
Code

android/app/src/main/java/com/openipc/decoder/RtspSession.java[R303-306]

             r = exchange(c, "SETUP", sdp.audioUrl,
-                        "Transport: RTP/AVP/TCP;unicast;interleaved=2-3\r\n" +
+                        "Transport: " + transport + "\r\n" +
                     "Session: " + session + "\r\n");
             if (!r.ok()) {
Evidence
Only the video reply can change the receiver choice. The UDP control reader uses a response parser
that skips, rather than delivers, interleaved frames.

android/app/src/main/java/com/openipc/decoder/RtspSession.java[286-310]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[334-343]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[421-434]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[589-601]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A successful audio SETUP can choose interleaved TCP while video remains UDP. The UDP control reader discards those audio frames.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[300-312]
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[421-434]
## Recommended Fix
Parse the audio SETUP Transport reply independently and route interleaved audio frames to the packet callback when transports are mixed, or explicitly disable that audio track if mixed reception cannot be supported.

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


4. Audio from a separate sender is dropped ✓ Resolved
Description
connect() passes only the video SETUP reply’s source to setSources(), even after a successful
audio SETUP has replaced the response. When that audio reply advertises a sender address different
from both the video sender and the RTSP peer, UdpReceiver rejects its RTP datagrams.
Code

android/app/src/main/java/com/openipc/decoder/RtspSession.java[R314-317]

+            if (rx != null) {
+                // Only the camera may send to our ports; "source" may be its own view of
+                // its address (NAT), so the address we connected to is accepted as well
+                rx.setSources(s.getInetAddress(), sourceAddress(reply.source));
Evidence
The reply used to configure accepted senders comes from video SETUP; audio SETUP does not update
it. The receiver drops packets whose sender is absent from that list.

android/app/src/main/java/com/openipc/decoder/RtspSession.java[286-318]
android/app/src/main/java/com/openipc/decoder/UdpReceiver.java[116-122]
android/app/src/main/java/com/openipc/decoder/UdpReceiver.java[128-164]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The UDP sender allowlist is built from the video SETUP reply alone, so audio from its own advertised source is rejected.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[290-318]
- android/app/src/main/java/com/openipc/decoder/UdpReceiver.java[116-122]
- android/app/src/main/java/com/openipc/decoder/UdpReceiver.java[153-164]
## Recommended Fix
Parse and retain each successful track's source address, then apply the appropriate source allowlist to that track's RTP channel.

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


5. A rapid restart can skip UDP teardown ✓ Resolved
Description
connect() clears the shared udpPlaying flag in its finally block without checking whether that
connection still owns the current receiver. If a stopped network thread finishes after a replacement
UDP session reaches PLAY, its cleanup changes the new session’s flag; the next stop closes the new
RTSP socket rather than leaving it open for that session’s TEARDOWN.
Code

android/app/src/main/java/com/openipc/decoder/RtspSession.java[R348-349]

+                if (udp == rx) udp = null;
+                udpPlaying = false;
Evidence
Stop permits a new generation before the old network thread finishes. Cleanup checks ownership for
socket and udp but not udpPlaying, whose value determines whether stop preserves the RTSP
socket for TEARDOWN.

android/app/src/main/java/com/openipc/decoder/RtspSession.java[115-153]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[328-361]
android/app/src/main/java/com/openipc/decoder/UdpReceiver.java[191-211]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An older UDP connection's delayed cleanup can overwrite the playback flag of a newer connection, changing how that connection is stopped.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[143-153]
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[328-361]
## Recommended Fix
Keep UDP playback state tied to the connection or receiver that set it, and clear it only when cleanup still owns the current connection.

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


View review recommended (1)
6. Auto forgets its TCP fallback on every resume ✓ Resolved
Description
RtspSession.configure() clears udpFailed even when the URL and transport are unchanged, and
rebuilding Quad cells creates fresh sessions that also lose the remembered Auto fallback. When UDP
is blocked, resuming or rebuilding Quad streams retries UDP for each affected cell, causing a black
screen during the 3-second first-packet timeout plus retry delay and opening an extra RTSP session
that sends a TEARDOWN, which matters for XM cameras limited to one client.
Code

android/app/src/main/java/com/openipc/decoder/RtspSession.java[R103-108]

+    void configure(String url, String userAgent, boolean withAudio, Transport.Mode mode) {
     this.url = url;
     this.userAgent = userAgent != null ? userAgent : "";
     this.withAudio = withAudio;
+        this.mode = mode != null ? mode : Transport.Mode.TCP;
+        this.udpFailed = false;
Evidence
udpFailed selects TCP after a UDP failure, but configure() clears it without checking whether
the URL or transport changed. Decoder reapplies configuration in startStreams() on resume and on
Quad toggles, while QuadCell.startThreads() configures a newly created session, so neither routine
startup nor a Quad rebuild preserves the prior fallback.

android/app/src/main/java/com/openipc/decoder/RtspSession.java[103-109]
android/app/src/main/java/com/openipc/decoder/Decoder.java[1047-1053]
android/app/src/main/java/com/openipc/decoder/QuadCell.java[163-168]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[421-447]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[263-266]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[438-447]
android/app/src/main/java/com/openipc/decoder/Decoder.java[1013-1052]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Auto transport loses its remembered UDP failure when an unchanged slot is configured again or its Quad cell is rebuilt. On UDP-blocking networks, routine resumes and Quad rebuilds therefore retry UDP and wait through the first-packet timeout.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[103-109]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[99-100]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1021-1052]
- android/app/src/main/java/com/openipc/decoder/QuadCell.java[70-77]
## Recommended Fix
In `RtspSession.configure()`, reset `udpFailed` only when the configured camera URL or transport mode changes; preserve it when startup reapplies unchanged configuration. Since Quad rebuilds create new sessions, also retain the fallback per slot outside the session—for example, in a `boolean[] udpFailed` in `Decoder`—and clear it when settings are saved or a shared link changes the slot. Pass that state into new sessions, or have sessions report changes through a callback, so resumed streams and new Quad cells start on TCP after a prior UDP failure.

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


Grey Divider

Tip of the day
💡 Did you know, you can commit Qodo's fix in one click with committable suggestions (GitHub & GitLab)

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread android/app/src/main/java/com/openipc/decoder/RtspSession.java
Comment thread android/app/src/main/java/com/openipc/decoder/RtspSession.java
Comment thread android/app/src/main/java/com/openipc/decoder/RtspSession.java
Comment thread android/app/src/main/java/com/openipc/decoder/RtspSession.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/RtspSession.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/RtspSession.java Outdated
@compot-dev

Copy link
Copy Markdown
Collaborator Author

Code done, CI green, Qodo findings addressed. Waiting for the device round in #74.

Every camera slot gets a transport setting: TCP (as before, the default),
UDP, or Auto (UDP, falling back to TCP). It is chosen in the URL dialog and
can be passed with a shared link (--es transport udp|tcp|auto).

- UdpReceiver binds an even RTP port and the RTCP port above it per track,
  always ephemeral (fixed ports were the bug of #40), and receives every
  track on the network thread through one selector, so callbacks keep
  coming from a single thread as over TCP. Only datagrams from the camera
  (the RTSP peer or the reply's "source") are accepted.
- RtpReorder puts packets back in sequence order. In-order packets pass
  straight through; a gap is waited for at most 40 ms or 64 packets, then
  skipped; late duplicates are dropped.
- Transport parses the SETUP reply, which decides what is received:
  Majestic answers "RTP/AVP/UDP;...", XM cameras "RTP/AVP;...;source=...;
  ssrc=...", and a server may answer with interleaved TCP.
- Over UDP a second thread reads the RTSP replies (keepalives). Closing a
  UDP session sends TEARDOWN, since the server otherwise keeps sending
  until the session times out, and XM cameras allow one client only.
- Auto falls back to TCP on a refused UDP SETUP (in the same connection)
  or when no video datagram arrives within 3 s after PLAY, and stays on
  TCP until the slot is reconfigured.
- A multicast reply, or one naming other client ports, is a UDP failure:
  Auto falls back to TCP, UDP reports the error, instead of waiting for
  datagrams that cannot arrive.
- Audio answered with a different transport than UDP video is not set up;
  the audio reply's source is accepted as a sender too.
- A finished connection clears the UDP state only if it still owns it, so
  a quick restart keeps the new session's TEARDOWN.
- Auto remembers a fallback per URL for 10 minutes across sessions, so a
  resume or a rebuilt grid does not try UDP again at once.
@compot-dev
compot-dev changed the base branch from feat/eight-slots to v2 October 12, 2026 19:51
@compot-dev

Copy link
Copy Markdown
Collaborator Author

Re Malformed UDP replies stall playback: declined for now. With Auto, a SETUP reply whose transport cannot deliver falls back to TCP after the 3 s silence timeout, which is the safety net designed for this. Strict client/server port validation would reject servers behind NAT or with port rewriting that still deliver. With UDP (no fallback) the user chose UDP explicitly. If a real camera turns up with a broken Transport header, it gets its own issue.

Device test (CI build of the full stack, rebased onto v2): all applicable #70 items pass, see #74 run 1. Auto with UDP blocked could not be tested (no iptables on the cameras).

@compot-dev
compot-dev marked this pull request as ready for review October 12, 2026 20:25
@compot-dev compot-dev removed the awaiting device test PR ready, merge only after testing on a real device label Oct 12, 2026
@compot-dev
compot-dev merged commit 1a279af into v2 Oct 12, 2026
4 checks passed
@compot-dev
compot-dev deleted the feat/rtsp-udp branch October 13, 2026 17:16
compot-dev added a commit that referenced this pull request Oct 15, 2026
feat: RTSP over UDP unicast, with fallback to TCP (#67)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RTSP over UDP unicast, with fallback to TCP

1 participant