From 4b90de4254e6d0e59418fce0907ecb00f3339580 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 10:59:49 +0000 Subject: [PATCH 1/4] fix: H.265 FU reassembly writes the real second NAL header byte The start fragment wrote only the first byte of the 2-byte NAL header and then copied from the FU header, so the FU header became the second byte (nuh_layer_id 18, TemporalId 2 for an IDR). Conforming decoders ignore such NAL units. Copy the layer-id/TID byte from the payload header, mask the FU type with 0x3F and skip payload header + FU header (fixes #43). --- .../src/main/java/com/openipc/decoder/NalAssembler.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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; From 92f60dc404cd6cbab52f22f5da579045a55bccac Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 10:59:49 +0000 Subject: [PATCH 2/4] test: add JVM unit tests and a lint baseline 28 tests for the pure logic: SDP parsing and AudioConfig, G.711/L16 conversion, AAC AudioSpecificConfig and AU-header bit reading, RTP header length/padding, RTP-over-TCP framing (EOF, truncation, resync), RTSP response parsing and NalAssembler (H.264 single/FU-A, H.265 single/FU, codec switch). android.jar stubs return default values. The lint baseline records the 16 known findings, so lintDebug only fails on new ones. --- android/app/build.gradle | 14 ++ android/app/lint-baseline.xml | 174 ++++++++++++++++++ .../com/openipc/decoder/AudioDecodeTest.java | 67 +++++++ .../com/openipc/decoder/NalAssemblerTest.java | 84 +++++++++ .../java/com/openipc/decoder/RtpTest.java | 78 ++++++++ .../com/openipc/decoder/RtspResponseTest.java | 53 ++++++ .../com/openipc/decoder/SdpParserTest.java | 120 ++++++++++++ android/gradle/libs.versions.toml | 4 + 8 files changed, 594 insertions(+) create mode 100644 android/app/lint-baseline.xml create mode 100644 android/app/src/test/java/com/openipc/decoder/AudioDecodeTest.java create mode 100644 android/app/src/test/java/com/openipc/decoder/NalAssemblerTest.java create mode 100644 android/app/src/test/java/com/openipc/decoder/RtpTest.java create mode 100644 android/app/src/test/java/com/openipc/decoder/RtspResponseTest.java create mode 100644 android/app/src/test/java/com/openipc/decoder/SdpParserTest.java 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/test/java/com/openipc/decoder/AudioDecodeTest.java b/android/app/src/test/java/com/openipc/decoder/AudioDecodeTest.java new file mode 100644 index 00000000..01a22bb3 --- /dev/null +++ b/android/app/src/test/java/com/openipc/decoder/AudioDecodeTest.java @@ -0,0 +1,67 @@ +/* + * 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.assertTrue; + +import org.junit.Test; + +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)); + } +} 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" } From 7c0420a6403f296b7c8eaf87bc63d94357a7ccf6 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 10:59:49 +0000 Subject: [PATCH 3/4] ci: run unit tests and lint before the release build A new 'test' job runs testDebugUnitTest and lintDebug on every push and pull request (including forks, no secrets needed) and uploads the reports on failure. The signed build depends on it and is skipped for pull requests (fixes #34). --- .github/workflows/build.yml | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 69f588cf..628cd76e 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,44 @@ 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 lintDebug + + - 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 From 0eb7a6698a418a0752052b1fb941c97ee4595a5e Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Mon, 12 Oct 2026 11:07:14 +0000 Subject: [PATCH 4/4] test/ci: cover ADTS and AU-header splitting; lint only with a baseline - AudioManager.splitAdts() and splitAuHeaders() are now static helpers (behaviour unchanged) with tests for single and multiple frames, bad sync, layer bits, too-small and truncated frames, two AUs, fragmented AUs and short payloads. - CI: when a manual build targets a commit without lint-baseline.xml, unit tests still run but lint is skipped, so older commits can still be released. --- .github/workflows/build.yml | 9 +- .../com/openipc/decoder/AudioManager.java | 85 ++++++++++++------- .../com/openipc/decoder/AudioDecodeTest.java | 85 +++++++++++++++++++ 3 files changed, 148 insertions(+), 31 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 628cd76e..234b64f4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,7 +33,14 @@ jobs: - name: Unit tests and lint working-directory: android - run: ./gradlew testDebugUnitTest lintDebug + 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() 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/test/java/com/openipc/decoder/AudioDecodeTest.java b/android/app/src/test/java/com/openipc/decoder/AudioDecodeTest.java index 01a22bb3..f71ca41f 100644 --- a/android/app/src/test/java/com/openipc/decoder/AudioDecodeTest.java +++ b/android/app/src/test/java/com/openipc/decoder/AudioDecodeTest.java @@ -9,10 +9,14 @@ 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() { @@ -64,4 +68,85 @@ public void readBitsAcrossBytes() { 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()); + } }