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" }