diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 69f588cf..234b64f4 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,6 +1,7 @@
name: Build
on:
push:
+ pull_request:
workflow_dispatch:
inputs:
commit_hash:
@@ -9,7 +10,51 @@ on:
type: string
jobs:
+ # Unit tests and lint; also runs for pull requests from forks (no secrets needed)
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Switch to specified commit (keep latest workflow files)
+ if: inputs.commit_hash != ''
+ run: |
+ git fetch --unshallow
+ git checkout ${{ inputs.commit_hash }}
+ git checkout ${{ github.ref_name }} -- .github/
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: 17
+ cache: gradle
+
+ - name: Unit tests and lint
+ working-directory: android
+ run: |
+ ./gradlew testDebugUnitTest
+ # Commits from before the lint baseline would fail on known findings
+ if [ -f app/lint-baseline.xml ]; then
+ ./gradlew lintDebug
+ else
+ echo "No lint baseline in this commit, lint skipped"
+ fi
+
+ - name: Upload reports
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-and-lint-reports
+ path: |
+ android/app/build/reports/
+ android/app/build/test-results/
+
+ # Signed release build; needs secrets, so it does not run for pull requests
build:
+ needs: test
+ if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Checkout
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 889bf11e..baf2f084 100755
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -57,4 +57,18 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
+
+ testOptions {
+ // android.jar stubs (Log, ...) return defaults instead of throwing in JVM unit tests
+ unitTests.returnDefaultValues = true
+ }
+
+ lint {
+ // Known issues are recorded in the baseline; only new ones fail the build
+ baseline = file("lint-baseline.xml")
+ }
+}
+
+dependencies {
+ testImplementation libs.junit
}
diff --git a/android/app/lint-baseline.xml b/android/app/lint-baseline.xml
new file mode 100644
index 00000000..e067a404
--- /dev/null
+++ b/android/app/lint-baseline.xml
@@ -0,0 +1,174 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/openipc/decoder/AudioManager.java b/android/app/src/main/java/com/openipc/decoder/AudioManager.java
index 943a1017..e2a998b1 100644
--- a/android/app/src/main/java/com/openipc/decoder/AudioManager.java
+++ b/android/app/src/main/java/com/openipc/decoder/AudioManager.java
@@ -16,7 +16,10 @@
import android.util.Log;
import java.nio.ByteBuffer;
+import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
@@ -289,46 +292,68 @@ private void releaseAacDecoder() {
}
private void processAac(AudioConfig c, byte[] data, int start, int end) {
+ List units;
if (c.sizeLength > 0) {
- // RFC 3640 mpeg4-generic: AU-headers-length (bits), AU headers, then AU data
- if (end - start < 2) return;
- int headerBits = ((data[start] & 0xFF) << 8) | (data[start + 1] & 0xFF);
- int bitPos = (start + 2) * 8;
- int bitEnd = bitPos + headerBits;
- int pos = start + 2 + (headerBits + 7) / 8;
- if (pos > end) return;
ensureAacDecoder(c, false);
- boolean first = true;
- while (true) {
- int headerLen = c.sizeLength + (first ? c.indexLength : c.indexDeltaLength);
- if (bitPos + headerLen > bitEnd) break;
- int size = readBits(data, bitPos, c.sizeLength);
- bitPos += headerLen;
- first = false;
- // AUs fragmented across packets are not supported
- if (size <= 0 || pos + size > end) break;
- offerAac(Arrays.copyOfRange(data, pos, pos + size));
- pos += size;
- }
+ units = splitAuHeaders(data, start, end,
+ c.sizeLength, c.indexLength, c.indexDeltaLength);
} else if (end - start >= 7 && isAdtsSync(data, start)) {
ensureAacDecoder(c, true);
- int pos = start;
- while (end - pos >= 7 && isAdtsSync(data, pos)) {
- int frameLen = ((data[pos + 3] & 0x03) << 11)
- | ((data[pos + 4] & 0xFF) << 3)
- | ((data[pos + 5] & 0xE0) >>> 5);
- if (frameLen < 7 || pos + frameLen > end) break;
- offerAac(Arrays.copyOfRange(data, pos, pos + frameLen));
- pos += frameLen;
- }
+ units = splitAdts(data, start, end);
} else {
// Raw access unit without AU headers
ensureAacDecoder(c, false);
- offerAac(Arrays.copyOfRange(data, start, end));
+ units = Collections.singletonList(new int[] {start, end - start});
+ }
+ for (int[] u : units) {
+ offerAac(Arrays.copyOfRange(data, u[0], u[0] + u[1]));
+ }
+ }
+
+ /**
+ * RFC 3640 mpeg4-generic payload: AU-headers-length (bits), AU headers, then AU data.
+ * Returns {offset, length} of each complete AU; AUs fragmented across packets are
+ * not supported and end the list.
+ */
+ static List splitAuHeaders(byte[] data, int start, int end,
+ int sizeLength, int indexLength, int indexDeltaLength) {
+ List units = new ArrayList<>();
+ if (end - start < 2 || sizeLength <= 0) return units;
+ int headerBits = ((data[start] & 0xFF) << 8) | (data[start + 1] & 0xFF);
+ int bitPos = (start + 2) * 8;
+ int bitEnd = bitPos + headerBits;
+ int pos = start + 2 + (headerBits + 7) / 8;
+ if (pos > end) return units;
+ boolean first = true;
+ while (true) {
+ int headerLen = sizeLength + (first ? indexLength : indexDeltaLength);
+ if (bitPos + headerLen > bitEnd) break;
+ int size = readBits(data, bitPos, sizeLength);
+ bitPos += headerLen;
+ first = false;
+ if (size <= 0 || pos + size > end) break;
+ units.add(new int[] {pos, size});
+ pos += size;
+ }
+ return units;
+ }
+
+ /** ADTS frames: {offset, length} of each complete frame, stopping at bad sync or length. */
+ static List splitAdts(byte[] data, int start, int end) {
+ List frames = new ArrayList<>();
+ int pos = start;
+ while (end - pos >= 7 && isAdtsSync(data, pos)) {
+ int frameLen = ((data[pos + 3] & 0x03) << 11)
+ | ((data[pos + 4] & 0xFF) << 3)
+ | ((data[pos + 5] & 0xE0) >>> 5);
+ if (frameLen < 7 || pos + frameLen > end) break;
+ frames.add(new int[] {pos, frameLen});
+ pos += frameLen;
}
+ return frames;
}
- private static boolean isAdtsSync(byte[] data, int pos) {
+ static boolean isAdtsSync(byte[] data, int pos) {
return (data[pos] & 0xFF) == 0xFF && (data[pos + 1] & 0xF6) == 0xF0;
}
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 b9a30c22..c1b4788c 100644
--- a/android/app/src/main/java/com/openipc/decoder/NalAssembler.java
+++ b/android/app/src/main/java/com/openipc/decoder/NalAssembler.java
@@ -68,9 +68,10 @@ Frame assemble(Frame frame, boolean codecH265) {
nalBuffer[nalSize++] = 0;
nalBuffer[nalSize++] = 0;
nalBuffer[nalSize++] = 1;
- nalBuffer[nalSize++] = (byte)((rx[cp] & 0x81) | ((rx[cp + 2] & 0x7F) << 1));
- int donlField = (rx[cp + 1] & 0x80) >> 7;
- cp += donlField != 0 ? 3 : 2;
+ // 2-byte NAL header: F + FU type + layer-id MSB, then layer-id/TID as sent
+ nalBuffer[nalSize++] = (byte)((rx[cp] & 0x81) | ((rx[cp + 2] & 0x3F) << 1));
+ nalBuffer[nalSize++] = rx[cp + 1];
+ cp += 3; // payload header + FU header
} else {
nalBuffer[nalSize++] = 0;
nalBuffer[nalSize++] = 0;
diff --git a/android/app/src/test/java/com/openipc/decoder/AudioDecodeTest.java b/android/app/src/test/java/com/openipc/decoder/AudioDecodeTest.java
new file mode 100644
index 00000000..f71ca41f
--- /dev/null
+++ b/android/app/src/test/java/com/openipc/decoder/AudioDecodeTest.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
+ *
+ * AudioDecodeTest.java — G.711 / L16 conversion and AAC helpers
+ *
+ */
+
+package com.openipc.decoder;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+
+public class AudioDecodeTest {
+ @Test
+ public void ulawReferenceValues() {
+ assertEquals(0, AudioManager.ulawToLinear(0xFF));
+ assertEquals(0, AudioManager.ulawToLinear(0x7F));
+ assertEquals(-32124, AudioManager.ulawToLinear(0x00));
+ assertEquals(32124, AudioManager.ulawToLinear(0x80));
+ for (int i = 0x80; i < 0xFF; i++) {
+ assertTrue("monotonic at " + i,
+ AudioManager.ulawToLinear(i) > AudioManager.ulawToLinear(i + 1));
+ }
+ }
+
+ @Test
+ public void alawReferenceValues() {
+ assertEquals(8, AudioManager.alawToLinear(0xD5));
+ assertEquals(-8, AudioManager.alawToLinear(0x55));
+ assertEquals(32256, AudioManager.alawToLinear(0xAA));
+ assertEquals(-32256, AudioManager.alawToLinear(0x2A));
+ }
+
+ @Test
+ public void g711OutputIsLittleEndian16Bit() {
+ short[] table = new short[256];
+ table[0x10] = 0x1234;
+ byte[] pcm = AudioManager.decodeG711(new byte[] {9, 0x10}, 1, 2, table);
+ assertArrayEquals(new byte[] {0x34, 0x12}, pcm);
+ }
+
+ @Test
+ public void l16SwapsBigEndianAndDropsOddByte() {
+ byte[] in = {9, 1, 2, 3, 4, 5};
+ assertArrayEquals(new byte[] {2, 1, 4, 3}, AudioManager.decodeL16(in, 1, 6, true));
+ assertArrayEquals(new byte[] {1, 2, 3, 4}, AudioManager.decodeL16(in, 1, 6, false));
+ }
+
+ @Test
+ public void aacAudioSpecificConfig() {
+ assertArrayEquals(new byte[] {0x11, (byte) 0x88}, AudioManager.buildAacConfig(48000, 1));
+ assertArrayEquals(new byte[] {0x12, 0x10}, AudioManager.buildAacConfig(44100, 2));
+ assertArrayEquals(new byte[] {0x15, (byte) 0x88}, AudioManager.buildAacConfig(8000, 1));
+ }
+
+ @Test
+ public void readBitsAcrossBytes() {
+ // AU header: size = 5 (13 bits), index = 0 (3 bits)
+ byte[] h = {0x00, 0x28};
+ assertEquals(5, AudioManager.readBits(h, 0, 13));
+ assertEquals(0, AudioManager.readBits(h, 13, 3));
+ assertEquals(0b1011, AudioManager.readBits(new byte[] {(byte) 0b0101_1000}, 1, 4));
+ }
+
+ /** ADTS frame of the given total length (7-byte header without CRC + payload). */
+ private static byte[] adts(int length, int fill) {
+ byte[] f = new byte[length];
+ f[0] = (byte) 0xFF;
+ f[1] = (byte) 0xF1;
+ f[2] = 0x50;
+ f[3] = (byte) (0x80 | ((length >> 11) & 0x03));
+ f[4] = (byte) ((length >> 3) & 0xFF);
+ f[5] = (byte) (((length & 0x07) << 5) | 0x1F);
+ f[6] = (byte) 0xFC;
+ for (int i = 7; i < length; i++) f[i] = (byte) fill;
+ return f;
+ }
+
+ private static byte[] concat(byte[]... parts) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ for (byte[] p : parts) out.write(p, 0, p.length);
+ return out.toByteArray();
+ }
+
+ @Test
+ public void adtsSingleAndMultipleFrames() {
+ byte[] one = adts(20, 1);
+ List f = AudioManager.splitAdts(one, 0, one.length);
+ assertEquals(1, f.size());
+ assertArrayEquals(new int[] {0, 20}, f.get(0));
+
+ // Two frames after a 12-byte RTP header
+ byte[] two = concat(new byte[12], adts(10, 2), adts(15, 3));
+ f = AudioManager.splitAdts(two, 12, two.length);
+ assertEquals(2, f.size());
+ assertArrayEquals(new int[] {12, 10}, f.get(0));
+ assertArrayEquals(new int[] {22, 15}, f.get(1));
+ }
+
+ @Test
+ public void adtsStopsAtBadSyncLengthOrTruncation() {
+ // Trailing truncated frame is dropped
+ byte[] truncated = concat(adts(10, 1), java.util.Arrays.copyOf(adts(30, 2), 20));
+ assertEquals(1, AudioManager.splitAdts(truncated, 0, truncated.length).size());
+
+ // Garbage after a valid frame stops the split
+ byte[] garbage = concat(adts(10, 1), new byte[] {0x12, 0x34, 0, 0, 0, 0, 0, 0});
+ assertEquals(1, AudioManager.splitAdts(garbage, 0, garbage.length).size());
+
+ // Frame length smaller than the header is rejected
+ byte[] bad = adts(10, 1);
+ bad[4] = 0;
+ bad[5] = (byte) ((3 << 5) | 0x1F); // length 3
+ assertEquals(0, AudioManager.splitAdts(bad, 0, bad.length).size());
+
+ // Layer bits must be zero
+ byte[] layer = adts(10, 1);
+ layer[1] = (byte) 0xF7;
+ assertFalse(AudioManager.isAdtsSync(layer, 0));
+ assertTrue(AudioManager.isAdtsSync(adts(10, 1), 0));
+ }
+
+ @Test
+ public void rfc3640TwoAccessUnits() {
+ // AU-headers-length 32 bits: two 16-bit headers (size 13 bits + index 3 bits)
+ byte[] p = concat(new byte[] {0x00, 0x20, 0x00, 0x18, 0x00, 0x10},
+ new byte[] {1, 1, 1}, new byte[] {2, 2});
+ List u = AudioManager.splitAuHeaders(p, 0, p.length, 13, 3, 3);
+ assertEquals(2, u.size());
+ assertArrayEquals(new int[] {6, 3}, u.get(0));
+ assertArrayEquals(new int[] {9, 2}, u.get(1));
+ }
+
+ @Test
+ public void rfc3640FragmentedOrShortPayload() {
+ // AU size 100 but only 3 bytes present: fragmented AU is not delivered
+ byte[] frag = {0x00, 0x10, 0x03, 0x20, 1, 2, 3};
+ assertEquals(0, AudioManager.splitAuHeaders(frag, 0, frag.length, 13, 3, 3).size());
+ // Header length points past the packet
+ byte[] past = {0x01, 0x00, 0x00};
+ assertEquals(0, AudioManager.splitAuHeaders(past, 0, past.length, 13, 3, 3).size());
+ // Too short for AU-headers-length
+ assertEquals(0, AudioManager.splitAuHeaders(new byte[] {0}, 0, 1, 13, 3, 3).size());
+ }
+}
diff --git a/android/app/src/test/java/com/openipc/decoder/NalAssemblerTest.java b/android/app/src/test/java/com/openipc/decoder/NalAssemblerTest.java
new file mode 100644
index 00000000..0e888747
--- /dev/null
+++ b/android/app/src/test/java/com/openipc/decoder/NalAssemblerTest.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
+ *
+ * NalAssemblerTest.java — NAL unit reassembly from RTP single NAL and FU packets
+ *
+ */
+
+package com.openipc.decoder;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+
+public class NalAssemblerTest {
+ private int codecSwitches;
+ private final NalAssembler nal = new NalAssembler(1024 * 1024, () -> codecSwitches++,
+ new FramePool(4));
+
+ /** RTP packet: 12-byte header followed by the given payload bytes. */
+ private static Frame rtp(int... payload) {
+ byte[] d = new byte[12 + payload.length];
+ d[0] = (byte) 0x80;
+ for (int i = 0; i < payload.length; i++) d[12 + i] = (byte) payload[i];
+ return new Frame(d, d.length);
+ }
+
+ private static byte[] bytes(Frame f) {
+ return Arrays.copyOf(f.data(), f.length());
+ }
+
+ private static byte[] b(int... v) {
+ byte[] r = new byte[v.length];
+ for (int i = 0; i < v.length; i++) r[i] = (byte) v[i];
+ return r;
+ }
+
+ @Test
+ public void h264SingleNal() {
+ Frame out = nal.assemble(rtp(0x67, 0x42, 0x00, 0x1F), false);
+ assertArrayEquals(b(0, 0, 0, 1, 0x67, 0x42, 0x00, 0x1F), bytes(out));
+ }
+
+ @Test
+ public void h264FuA() {
+ // FU indicator: NRI 3, type 28; FU header: S/E + type 5 (IDR)
+ assertNull(nal.assemble(rtp(0x7C, 0x85, 0xA1, 0xA2), false));
+ assertNull(nal.assemble(rtp(0x7C, 0x05, 0xB1), false));
+ Frame out = nal.assemble(rtp(0x7C, 0x45, 0xC1, 0xC2), false);
+ assertArrayEquals(b(0, 0, 0, 1, 0x65, 0xA1, 0xA2, 0xB1, 0xC1, 0xC2), bytes(out));
+ }
+
+ @Test
+ public void h265SingleNal() {
+ Frame out = nal.assemble(rtp(0x40, 0x01, 0x0C, 0x01), true);
+ assertArrayEquals(b(0, 0, 0, 1, 0x40, 0x01, 0x0C, 0x01), bytes(out));
+ }
+
+ @Test
+ public void h265Fu() {
+ // Payload header: type 49, layer 0, TID 1 (0x62 0x01); FU header: S/E + type 19 (IDR_W_RADL)
+ assertNull(nal.assemble(rtp(0x62, 0x01, 0x93, 0xA1, 0xA2), true));
+ assertNull(nal.assemble(rtp(0x62, 0x01, 0x13, 0xB1), true));
+ Frame out = nal.assemble(rtp(0x62, 0x01, 0x53, 0xC1), true);
+ // NAL header must be 0x26 0x01 (type 19, layer 0, TID 1)
+ assertArrayEquals(b(0, 0, 0, 1, 0x26, 0x01, 0xA1, 0xA2, 0xB1, 0xC1), bytes(out));
+ }
+
+ @Test
+ public void codecSwitchResetsAndNotifies() {
+ nal.assemble(rtp(0x67, 0x42), false);
+ assertEquals(0, codecSwitches);
+ nal.assemble(rtp(0x40, 0x01, 0x0C), true);
+ assertEquals(1, codecSwitches);
+ }
+
+ @Test
+ public void emptyPayloadIsIgnored() {
+ assertNull(nal.assemble(rtp(), false));
+ }
+}
diff --git a/android/app/src/test/java/com/openipc/decoder/RtpTest.java b/android/app/src/test/java/com/openipc/decoder/RtpTest.java
new file mode 100644
index 00000000..7890eb84
--- /dev/null
+++ b/android/app/src/test/java/com/openipc/decoder/RtpTest.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
+ *
+ * RtpTest.java — RTP header helpers and RTP-over-TCP interleaved framing
+ *
+ */
+
+package com.openipc.decoder;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import org.junit.Test;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+
+public class RtpTest {
+ @Test
+ public void headerLengthWithCsrcAndExtension() {
+ byte[] p = new byte[40];
+ p[0] = (byte) 0x80;
+ assertEquals(12, RtspClient.rtpHeaderLength(p, 40));
+ p[0] = (byte) 0x82; // 2 CSRC
+ assertEquals(20, RtspClient.rtpHeaderLength(p, 40));
+ p[0] = (byte) 0x90; // extension, length 2 words at bytes 14..15
+ p[15] = 2;
+ assertEquals(24, RtspClient.rtpHeaderLength(p, 40));
+ }
+
+ @Test
+ public void malformedHeaders() {
+ byte[] p = new byte[40];
+ p[0] = (byte) 0x40; // version 1
+ assertEquals(-1, RtspClient.rtpHeaderLength(p, 40));
+ p[0] = (byte) 0x80;
+ assertEquals(-1, RtspClient.rtpHeaderLength(p, 11));
+ p[0] = (byte) 0x90;
+ p[15] = 100; // extension longer than the packet
+ assertEquals(-1, RtspClient.rtpHeaderLength(p, 40));
+ }
+
+ @Test
+ public void paddingIsRemovedFromPayloadEnd() {
+ byte[] p = new byte[40];
+ p[0] = (byte) 0xA0;
+ p[39] = 4;
+ assertEquals(36, RtspClient.rtpPayloadEnd(p, 40));
+ p[0] = (byte) 0x80;
+ assertEquals(40, RtspClient.rtpPayloadEnd(p, 40));
+ }
+
+ private static BufferedInputStream stream(int... bytes) {
+ byte[] b = new byte[bytes.length];
+ for (int i = 0; i < bytes.length; i++) b[i] = (byte) bytes[i];
+ return new BufferedInputStream(new ByteArrayInputStream(b));
+ }
+
+ @Test
+ public void interleavedPacketAfterGarbage() throws IOException {
+ byte[] buf = new byte[65535];
+ // 'R' 'T' garbage, then $ channel 2 length 3
+ int total = RtspClient.readInterleavedPacket(stream('R', 'T', '$', 2, 0, 3, 7, 8, 9), buf);
+ assertEquals(7, total);
+ assertEquals(2, buf[1]);
+ assertEquals(9, buf[6]);
+ }
+
+ @Test
+ public void cleanEofAndTruncation() throws IOException {
+ byte[] buf = new byte[65535];
+ assertEquals(-1, RtspClient.readInterleavedPacket(stream(), buf));
+ assertThrows(IOException.class, () -> RtspClient.readInterleavedPacket(stream('$', 0), buf));
+ assertThrows(IOException.class,
+ () -> RtspClient.readInterleavedPacket(stream('$', 0, 0, 5, 1, 2), buf));
+ }
+}
diff --git a/android/app/src/test/java/com/openipc/decoder/RtspResponseTest.java b/android/app/src/test/java/com/openipc/decoder/RtspResponseTest.java
new file mode 100644
index 00000000..ed204bff
--- /dev/null
+++ b/android/app/src/test/java/com/openipc/decoder/RtspResponseTest.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
+ *
+ * RtspResponseTest.java — RTSP status line and header parsing
+ *
+ */
+
+package com.openipc.decoder;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+public class RtspResponseTest {
+ private static InputStream in(String s) {
+ return new ByteArrayInputStream(s.getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Test
+ public void returnsRequestedHeaderValueWithoutParameters() throws IOException {
+ String session = RtspClient.readRtspResponse(
+ in("RTSP/1.0 200 OK\r\nCSeq: 2\r\nsession: 12345678;timeout=60\r\n\r\n"),
+ "Session:");
+ assertEquals("12345678", session);
+ }
+
+ @Test
+ public void missingHeaderIsNull() throws IOException {
+ assertNull(RtspClient.readRtspResponse(in("RTSP/1.0 200 OK\r\nCSeq: 2\r\n\r\n"), "Session:"));
+ }
+
+ @Test
+ public void errorAndMalformedStatusThrow() {
+ assertThrows(IOException.class, () -> RtspClient.readRtspResponse(
+ in("RTSP/1.0 401 Unauthorized\r\n\r\n"), null));
+ assertThrows(IOException.class, () -> RtspClient.readRtspResponse(in("garbage\r\n\r\n"), null));
+ assertThrows(IOException.class, () -> RtspClient.readRtspResponse(in(""), null));
+ }
+
+ @Test
+ public void tooLongLineIsRejected() {
+ StringBuilder sb = new StringBuilder("RTSP/1.0 200 OK\r\nX: ");
+ for (int i = 0; i < 9000; i++) sb.append('a');
+ assertThrows(IOException.class, () -> RtspClient.readRtspResponse(in(sb + "\r\n\r\n"), null));
+ }
+}
diff --git a/android/app/src/test/java/com/openipc/decoder/SdpParserTest.java b/android/app/src/test/java/com/openipc/decoder/SdpParserTest.java
new file mode 100644
index 00000000..dd052369
--- /dev/null
+++ b/android/app/src/test/java/com/openipc/decoder/SdpParserTest.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
+ *
+ * SdpParserTest.java — SDP parsing into track URLs, video codec and AudioConfig
+ *
+ */
+
+package com.openipc.decoder;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public class SdpParserTest {
+ private static final String OPENIPC_LIKE =
+ "v=0\r\n" +
+ "o=- 0 0 IN IP4 0.0.0.0\r\n" +
+ "s=x\r\n" +
+ "m=video 0 RTP/AVP 97\r\n" +
+ "a=rtpmap:97 H265/90000\r\n" +
+ "a=control:trackID=0\r\n" +
+ "m=audio 0 RTP/AVP 98\r\n" +
+ "a=rtpmap:98 mpeg4-generic/48000/2\r\n" +
+ "a=fmtp:98 streamtype=5; mode=AAC-hbr; config=1190; SizeLength=13; " +
+ "IndexLength=3; IndexDeltaLength=3\r\n" +
+ "a=control:trackID=1\r\n";
+
+ private static String[] parse(String sdp, String base, boolean[] h265, AudioConfig audio) {
+ return RtspClient.parseSdp(sdp, base, h265, audio);
+ }
+
+ @Test
+ public void videoAndAacTrack() {
+ boolean[] h265 = new boolean[1];
+ AudioConfig a = new AudioConfig();
+ String[] urls = parse(OPENIPC_LIKE, "rtsp://cam/stream=0", h265, a);
+
+ assertTrue(h265[0]);
+ assertEquals("rtsp://cam/stream=0/trackID=0", urls[0]);
+ assertEquals("rtsp://cam/stream=0/trackID=1", urls[1]);
+ assertEquals(AudioConfig.AAC, a.codec);
+ assertEquals(98, a.pt);
+ assertEquals(48000, a.sampleRate);
+ assertEquals(2, a.channels);
+ assertArrayEquals(new byte[] {0x11, (byte) 0x90}, a.aacConfig);
+ assertEquals(13, a.sizeLength);
+ assertEquals(3, a.indexLength);
+ assertEquals(3, a.indexDeltaLength);
+ }
+
+ @Test
+ public void fmtpBeforeRtpmapKeepsAuHeaderLayout() {
+ AudioConfig a = new AudioConfig();
+ parse("m=audio 0 RTP/AVP 98\r\n" +
+ "a=fmtp:98 config=1408; sizeLength=13; indexLength=3; indexDeltaLength=3\r\n" +
+ "a=rtpmap:98 MPEG4-GENERIC/16000/1\r\n", "rtsp://x", null, a);
+ assertEquals(AudioConfig.AAC, a.codec);
+ assertEquals(13, a.sizeLength);
+ assertEquals(16000, a.sampleRate);
+ }
+
+ @Test
+ public void invalidAuHeaderLayoutDisablesAac() {
+ AudioConfig a = new AudioConfig();
+ parse("m=audio 0 RTP/AVP 98\r\na=rtpmap:98 MPEG4-GENERIC/16000\r\n" +
+ "a=fmtp:98 sizelength=13; indexlength=-3\r\n", "rtsp://x", null, a);
+ assertEquals(AudioConfig.NONE, a.codec);
+
+ AudioConfig b = new AudioConfig();
+ parse("m=audio 0 RTP/AVP 98\r\na=rtpmap:98 MPEG4-GENERIC/16000\r\n" +
+ "a=fmtp:98 sizelength=40\r\n", "rtsp://x", null, b);
+ assertEquals(AudioConfig.NONE, b.codec);
+ }
+
+ @Test
+ public void staticPayloadTypesWithoutRtpmap() {
+ AudioConfig a = new AudioConfig();
+ parse("m=video 0 RTP/AVP 96\r\na=rtpmap:96 H264/90000\r\nm=audio 0 RTP/AVP 8\r\n",
+ "rtsp://x", null, a);
+ assertEquals(AudioConfig.PCMA, a.codec);
+ assertEquals(8000, a.sampleRate);
+
+ AudioConfig b = new AudioConfig();
+ parse("m=audio 0 RTP/AVP 0\r\n", "rtsp://x", null, b);
+ assertEquals(AudioConfig.PCMU, b.codec);
+ }
+
+ @Test
+ public void l16BigEndianAndRtpmapOfVideoIsIgnored() {
+ AudioConfig a = new AudioConfig();
+ parse("m=video 0 RTP/AVP 100\r\na=rtpmap:100 H264/90000\r\n" +
+ "m=audio 0 RTP/AVP 100\r\na=rtpmap:100 L16/16000\r\n", "rtsp://x", null, a);
+ assertEquals(AudioConfig.PCM, a.codec);
+ assertEquals(16000, a.sampleRate);
+ assertTrue(a.bigEndian);
+ }
+
+ @Test
+ public void unsupportedAndMissingAudio() {
+ AudioConfig a = new AudioConfig();
+ parse("m=audio 0 RTP/AVP 111\r\na=rtpmap:111 opus/48000/2\r\n", "rtsp://x", null, a);
+ assertEquals(AudioConfig.NONE, a.codec);
+
+ AudioConfig b = new AudioConfig();
+ parse("m=video 0 RTP/AVP 96\r\n", "rtsp://x", null, b);
+ assertEquals(AudioConfig.NONE, b.codec);
+ assertEquals(-1, b.pt);
+ }
+
+ @Test
+ public void absoluteControlAndNullOutputs() {
+ String[] urls = parse("m=video 0 RTP/AVP 96\r\na=control:rtsp://other/v\r\n",
+ "rtsp://cam/s", null, null);
+ assertEquals("rtsp://other/v", urls[0]);
+ assertNull(null);
+ }
+}
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index ba018ae3..9613c6d3 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -6,6 +6,10 @@
[versions]
agp = "8.13.1"
+junit = "4.13.2"
+
+[libraries]
+junit = { group = "junit", name = "junit", version.ref = "junit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }