diff options
Diffstat (limited to 'app/src/test')
8 files changed, 934 insertions, 0 deletions
diff --git a/app/src/test/java/invalid/lena/scrcpy/AtomicFilesTest.java b/app/src/test/java/invalid/lena/scrcpy/AtomicFilesTest.java new file mode 100644 index 0000000..7124fd2 --- /dev/null +++ b/app/src/test/java/invalid/lena/scrcpy/AtomicFilesTest.java @@ -0,0 +1,54 @@ +package invalid.lena.scrcpy; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.file.Files; + +public class AtomicFilesTest { + + @Rule + public TemporaryFolder dir = new TemporaryFolder(); + + @Test + public void writeCreatesFile() throws Exception { + File f = new File(dir.getRoot(), "x"); + AtomicFiles.write(f, "hello".getBytes()); + assertArrayEquals("hello".getBytes(), Files.readAllBytes(f.toPath())); + } + + @Test + public void writeReplacesExistingWhole() throws Exception { + File f = new File(dir.getRoot(), "x"); + AtomicFiles.write(f, "old".getBytes()); + AtomicFiles.write(f, "new-and-longer".getBytes()); + assertArrayEquals("new-and-longer".getBytes(), Files.readAllBytes(f.toPath())); + } + + @Test + public void writeLeavesNoTempBehind() throws Exception { + File f = new File(dir.getRoot(), "x"); + AtomicFiles.write(f, "data".getBytes()); + assertFalse(new File(dir.getRoot(), "x.tmp").exists()); + } + + @Test + public void staleTempDoesNotCorruptDestination() throws Exception { + // A crash after staging but before the rename leaves a partial ".tmp". + // The destination must still hold the previous good content, and the + // next successful write must overwrite the stale temp cleanly. + File f = new File(dir.getRoot(), "x"); + AtomicFiles.write(f, "good".getBytes()); + Files.write(new File(dir.getRoot(), "x.tmp").toPath(), "partial".getBytes()); + assertArrayEquals("good".getBytes(), Files.readAllBytes(f.toPath())); + + AtomicFiles.write(f, "good2".getBytes()); + assertArrayEquals("good2".getBytes(), Files.readAllBytes(f.toPath())); + assertFalse(new File(dir.getRoot(), "x.tmp").exists()); + } +} diff --git a/app/src/test/java/invalid/lena/scrcpy/AudioStreamTest.java b/app/src/test/java/invalid/lena/scrcpy/AudioStreamTest.java new file mode 100644 index 0000000..076d27c --- /dev/null +++ b/app/src/test/java/invalid/lena/scrcpy/AudioStreamTest.java @@ -0,0 +1,123 @@ +package invalid.lena.scrcpy; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class AudioStreamTest { + + private static final long FLAG_CONFIG = 1L << 62; + + private static final class RecordingFrames implements AudioFrames { + int starts = 0, releases = 0, startFourcc = 0; + final List<byte[]> feeds = new ArrayList<>(); + final List<Boolean> cfgs = new ArrayList<>(); + @Override public void start(int fourcc) { starts++; startFourcc = fourcc; } + @Override public void feed(byte[] data, int off, int len, boolean isConfig) { + byte[] cp = new byte[len]; + System.arraycopy(data, off, cp, 0, len); + feeds.add(cp); + cfgs.add(isConfig); + } + @Override public void release() { releases++; } + } + + private static byte[] fourcc(int v) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + new DataOutputStream(bos).writeInt(v); + return bos.toByteArray(); + } + + private static byte[] frame(long ptsAndFlags, byte[] payload) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bos); + out.writeLong(ptsAndFlags); + out.writeInt(payload.length); + out.write(payload); + return bos.toByteArray(); + } + + private static byte[] cat(byte[]... parts) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + for (byte[] p : parts) bos.write(p); + return bos.toByteArray(); + } + + @Test + public void rawCodecFeedsFrames() throws Exception { + byte[] pcm1 = new byte[]{1, 2, 3, 4}; + byte[] pcm2 = new byte[]{5, 6, 7, 8, 9, 10}; + byte[] bytes = cat( + fourcc(Wire.CODEC_RAW), + frame(1_000_000L, pcm1), + frame(2_000_000L, pcm2)); + + RecordingFrames sink = new RecordingFrames(); + new AudioStream(new ByteArrayInputStream(bytes), sink).run(); + + assertEquals(1, sink.starts); + assertEquals(Wire.CODEC_RAW, sink.startFourcc); + assertEquals(2, sink.feeds.size()); + assertEquals(pcm1.length, sink.feeds.get(0).length); + assertEquals(pcm2.length, sink.feeds.get(1).length); + for (int i = 0; i < pcm1.length; i++) assertEquals(pcm1[i], sink.feeds.get(0)[i]); + for (int i = 0; i < pcm2.length; i++) assertEquals(pcm2[i], sink.feeds.get(1)[i]); + assertEquals(false, sink.cfgs.get(0)); + assertEquals(false, sink.cfgs.get(1)); + } + + @Test + public void opusConfigFlagIsExposed() throws Exception { + byte[] head = new byte[]{ + 'O','p','u','s','H','e','a','d', // magic + 1, // version + 2, // channel count + (byte)0x38, 0x01, // pre-skip = 312 LE + (byte)0x80, (byte)0xBB, 0, 0, // sample rate 48000 LE + 0, 0, // output gain + 0 // channel mapping family + }; + byte[] pkt = new byte[]{0x10, 0x20, 0x30}; + byte[] bytes = cat( + fourcc(Wire.CODEC_OPUS), + frame(FLAG_CONFIG, head), + frame(0L, pkt)); + + RecordingFrames sink = new RecordingFrames(); + new AudioStream(new ByteArrayInputStream(bytes), sink).run(); + + assertEquals(1, sink.starts); + assertEquals(Wire.CODEC_OPUS, sink.startFourcc); + assertEquals(2, sink.feeds.size()); + assertTrue("first frame must carry FLAG_CONFIG", sink.cfgs.get(0)); + assertTrue("second frame must not carry FLAG_CONFIG", !sink.cfgs.get(1)); + assertEquals(head.length, sink.feeds.get(0).length); + assertEquals(pkt.length, sink.feeds.get(1).length); + } + + @Test + public void disabledCodecDoesNotStart() throws Exception { + byte[] bytes = fourcc(0); + RecordingFrames sink = new RecordingFrames(); + new AudioStream(new ByteArrayInputStream(bytes), sink).run(); + assertEquals(0, sink.starts); + assertEquals(0, sink.feeds.size()); + } + + @Test + public void errorCodecDoesNotStart() throws Exception { + byte[] bytes = fourcc(1); + RecordingFrames sink = new RecordingFrames(); + new AudioStream(new ByteArrayInputStream(bytes), sink).run(); + assertEquals(0, sink.starts); + assertEquals(0, sink.feeds.size()); + } +} diff --git a/app/src/test/java/invalid/lena/scrcpy/ControlMessagesTest.java b/app/src/test/java/invalid/lena/scrcpy/ControlMessagesTest.java new file mode 100644 index 0000000..a84e18e --- /dev/null +++ b/app/src/test/java/invalid/lena/scrcpy/ControlMessagesTest.java @@ -0,0 +1,65 @@ +package invalid.lena.scrcpy; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; + +public class ControlMessagesTest { + + @Test + public void touchByteLayout() { + // ACTION_DOWN=0, single finger, target 1080x2400, x=100, y=200, full pressure + byte[] m = ControlMessages.touch(/* action */ 0, /* pointerId */ 0L, + /* x */ 100, /* y */ 200, /* tw */ 1080, /* th */ 2400, + /* pressure */ 0xffff, /* actionButton */ 0, /* buttons */ 0); + assertEquals(ControlMessages.TOUCH_MSG_LEN, m.length); + assertEquals(ControlMessages.TYPE_INJECT_TOUCH_EVENT, m[0]); + assertEquals(0, m[1]); // action + assertEquals(0L, Wire.readBe64(m, 2)); // pointer id + assertEquals(100, Wire.readBe32(m, 10)); // x + assertEquals(200, Wire.readBe32(m, 14)); // y + assertEquals(1080, ((m[18] & 0xff) << 8) | (m[19] & 0xff)); // tw + assertEquals(2400, ((m[20] & 0xff) << 8) | (m[21] & 0xff)); // th + assertEquals(0xffff, ((m[22] & 0xff) << 8) | (m[23] & 0xff)); // pressure + assertEquals(0, Wire.readBe32(m, 24)); // actionButton + assertEquals(0, Wire.readBe32(m, 28)); // buttons + } + + @Test + public void keycodeByteLayout() { + // ACTION_DOWN=0, KEYCODE_A=29, repeat=0, metaState=0 + byte[] m = ControlMessages.keycode(0, 29, 0, 0); + assertEquals(ControlMessages.KEY_MSG_LEN, m.length); + assertEquals(ControlMessages.TYPE_INJECT_KEYCODE, m[0]); + assertEquals(0, m[1]); + assertEquals(29, Wire.readBe32(m, 2)); + assertEquals(0, Wire.readBe32(m, 6)); + assertEquals(0, Wire.readBe32(m, 10)); + } + + @Test + public void setClipboardWithUtf8Payload() { + String text = "héllo 🎉"; // includes a 4-byte surrogate pair + byte[] expected = text.getBytes(StandardCharsets.UTF_8); + byte[] m = ControlMessages.setClipboard(0xdeadbeefcafeL, /* paste */ true, text); + assertEquals(1 + 8 + 1 + 4 + expected.length, m.length); + assertEquals(ControlMessages.TYPE_SET_CLIPBOARD, m[0]); + assertEquals(0xdeadbeefcafeL, Wire.readBe64(m, 1)); + assertEquals(1, m[9]); // paste + assertEquals(expected.length, Wire.readBe32(m, 10)); + byte[] tail = new byte[expected.length]; + System.arraycopy(m, 14, tail, 0, expected.length); + assertArrayEquals(expected, tail); + } + + @Test + public void setClipboardNoPaste() { + byte[] m = ControlMessages.setClipboard(0L, false, ""); + assertEquals(0, m[9]); + assertEquals(0, Wire.readBe32(m, 10)); + assertEquals(14, m.length); + } +} diff --git a/app/src/test/java/invalid/lena/scrcpy/ControlStreamTest.java b/app/src/test/java/invalid/lena/scrcpy/ControlStreamTest.java new file mode 100644 index 0000000..cafff81 --- /dev/null +++ b/app/src/test/java/invalid/lena/scrcpy/ControlStreamTest.java @@ -0,0 +1,152 @@ +package invalid.lena.scrcpy; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +public class ControlStreamTest { + + private static final class RecordingSink implements ControlStream.InboundSink { + final List<String> clipboards = new ArrayList<>(); + @Override public void onRemoteClipboard(String text) { clipboards.add(text); } + } + + // ---- inbound (DeviceMessage) parsing ---- + + @Test + public void clipboardMessageDispatched() throws Exception { + String text = "héllo world"; + byte[] payload = text.getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bos); + out.writeByte(0); // TYPE_CLIPBOARD + out.writeInt(payload.length); + out.write(payload); + + RecordingSink sink = new RecordingSink(); + ControlStream cs = new ControlStream( + new ByteArrayInputStream(bos.toByteArray()), + new ByteArrayOutputStream()); + cs.setInboundSink(sink); + cs.runReader(); // synchronous; returns on EOF + + assertEquals(1, sink.clipboards.size()); + assertEquals(text, sink.clipboards.get(0)); + } + + @Test + public void ackClipboardConsumedSilently() throws Exception { + // type=1, then a long sequence number. No dispatch should occur. + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bos); + out.writeByte(1); + out.writeLong(0x1234567890abcdefL); + + RecordingSink sink = new RecordingSink(); + ControlStream cs = new ControlStream( + new ByteArrayInputStream(bos.toByteArray()), + new ByteArrayOutputStream()); + cs.setInboundSink(sink); + cs.runReader(); + + assertEquals(0, sink.clipboards.size()); + } + + @Test + public void unknownTypeAborts() throws Exception { + // type=42 - runReader should log and return. + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + bos.write(42); + // a few trailing bytes so the reader doesn't EOF before processing + bos.write(new byte[]{0, 0, 0, 0}); + + RecordingSink sink = new RecordingSink(); + ControlStream cs = new ControlStream( + new ByteArrayInputStream(bos.toByteArray()), + new ByteArrayOutputStream()); + cs.setInboundSink(sink); + cs.runReader(); + assertEquals(0, sink.clipboards.size()); + } + + // ---- outbound (send + writer) ---- + + @Test + public void writerDrainsInOrder() throws Exception { + // We submit three messages, then run the writer until the + // outbox is empty AND we've signalled stop. + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ControlStream cs = new ControlStream( + new ByteArrayInputStream(new byte[0]), out); + + byte[] a = new byte[]{1, 2, 3}; + byte[] b = new byte[]{4, 5}; + byte[] c = new byte[]{6}; + cs.send(a); + cs.send(b); + cs.send(c); + + Thread writer = new Thread(cs::runWriter, "writer-under-test"); + writer.start(); + + // give the writer a moment to drain + for (int i = 0; i < 50 && out.size() < 6; i++) Thread.sleep(10); + cs.stop(); + writer.join(1_000); + + byte[] got = out.toByteArray(); + assertEquals(6, got.length); + assertEquals(1, got[0]); assertEquals(2, got[1]); assertEquals(3, got[2]); + assertEquals(4, got[3]); assertEquals(5, got[4]); + assertEquals(6, got[5]); + } + + @Test + public void keyEventLandsAfterOutboxOverflow() throws Exception { + // Fill the bounded outbox with touch-MOVE messages, then enqueue + // one key event. The MOVE must be evicted to make room; the key + // event must be preserved and end up last in the drain. + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ControlStream cs = new ControlStream( + new ByteArrayInputStream(new byte[0]), out); + + for (int i = 0; i < 256; i++) { + cs.send(ControlMessages.touch(/* MOVE */ 2, 0L, i, i, 1080, 2400, 0xffff, 0, 0)); + } + byte[] key = ControlMessages.keycode(0, 29, 0, 0); + cs.send(key); + + Thread w = new Thread(cs::runWriter, "drain"); + w.start(); + + // Drain to quiescence (size stable for 100ms). + long deadline = System.currentTimeMillis() + 2_000; + int last = -1, stable = 0; + while (System.currentTimeMillis() < deadline) { + int now = out.size(); + if (now == last) { stable++; if (stable > 5) break; } + else { stable = 0; last = now; } + Thread.sleep(20); + } + cs.stop(); + w.join(1_000); + + byte[] bytes = out.toByteArray(); + assertNotEquals(0, bytes.length); + + // The key event should be the final 14 bytes of the drained stream + // (queued last, FIFO order, only touch-MOVEs evicted on overflow). + byte[] tail = new byte[14]; + System.arraycopy(bytes, bytes.length - 14, tail, 0, 14); + for (int i = 0; i < 14; i++) assertEquals(key[i], tail[i]); + } +} diff --git a/app/src/test/java/invalid/lena/scrcpy/DevicesTest.java b/app/src/test/java/invalid/lena/scrcpy/DevicesTest.java new file mode 100644 index 0000000..d1e4f02 --- /dev/null +++ b/app/src/test/java/invalid/lena/scrcpy/DevicesTest.java @@ -0,0 +1,64 @@ +package invalid.lena.scrcpy; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class DevicesTest { + + @Test + public void parseEmptyOrNull() { + assertTrue(Devices.parse(null).isEmpty()); + assertTrue(Devices.parse("").isEmpty()); + assertTrue(Devices.parse(" ").isEmpty()); + } + + @Test + public void parseNonArrayReturnsEmpty() { + assertTrue(Devices.parse("{not json").isEmpty()); + assertTrue(Devices.parse("{\"host\":\"x\"}").isEmpty()); + } + + @Test + public void parseSkipsMalformedRowsKeepsValid() { + // Middle row missing 'port' - must NOT nuke the other two. + String json = "[" + + "{\"host\":\"a\",\"port\":1}," + + "{\"host\":\"bad\"}," + + "{\"host\":\"c\",\"port\":3}" + + "]"; + List<Devices.Device> got = Devices.parse(json); + assertEquals(2, got.size()); + assertEquals("a", got.get(0).host); + assertEquals("c", got.get(1).host); + } + + @Test + public void roundTrip() { + List<Devices.Device> in = new ArrayList<>(Arrays.asList( + new Devices.Device("192.168.1.10", 5555), + new Devices.Device("10.0.0.2", 43210))); + String json = Devices.serialize(in); + List<Devices.Device> out = Devices.parse(json); + assertEquals(2, out.size()); + assertEquals("192.168.1.10", out.get(0).host); + assertEquals(5555, out.get(0).port); + assertEquals("10.0.0.2", out.get(1).host); + assertEquals(43210, out.get(1).port); + } + + @Test + public void deviceEqualsByHostAndPort() { + Devices.Device a = new Devices.Device("1.1.1.1", 5555); + Devices.Device b = new Devices.Device("1.1.1.1", 5555); + Devices.Device c = new Devices.Device("1.1.1.1", 5556); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertEquals(false, a.equals(c)); + } +} diff --git a/app/src/test/java/invalid/lena/scrcpy/SyncTest.java b/app/src/test/java/invalid/lena/scrcpy/SyncTest.java new file mode 100644 index 0000000..57493ec --- /dev/null +++ b/app/src/test/java/invalid/lena/scrcpy/SyncTest.java @@ -0,0 +1,137 @@ +package invalid.lena.scrcpy; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public class SyncTest { + + private static final String PATH = "/data/local/tmp/scrcpy-server.jar"; + private static final int MODE = 0100644; + private static final int MTIME = 0x12345678; + + @Test + public void emptyPayloadFraming() throws Exception { + byte[] payload = new byte[0]; + ByteArrayOutputStream framed = new ByteArrayOutputStream(); + ByteArrayInputStream resp = okayResponse(); + + long total = Sync.push(new ByteArrayInputStream(payload), framed, resp, PATH, MODE, MTIME); + assertEquals(0L, total); + + byte[] bytes = framed.toByteArray(); + int pos = 0; + + // SEND + le32(headerLen) + header + String header = PATH + "," + MODE; + byte[] hb = header.getBytes(StandardCharsets.UTF_8); + pos = assertTag(bytes, pos, "SEND"); + assertEquals(hb.length, Wire.readLe32(bytes, pos)); pos += 4; + for (int i = 0; i < hb.length; i++, pos++) assertEquals(hb[i], bytes[pos]); + + // No DATA chunks for empty payload. + // DONE + le32(mtime) + pos = assertTag(bytes, pos, "DONE"); + assertEquals(MTIME, Wire.readLe32(bytes, pos)); pos += 4; + + assertEquals(bytes.length, pos); + } + + @Test + public void singleByteIsOneDataChunk() throws Exception { + ByteArrayOutputStream framed = new ByteArrayOutputStream(); + long total = Sync.push(new ByteArrayInputStream(new byte[]{0x42}), + framed, okayResponse(), PATH, MODE, MTIME); + assertEquals(1L, total); + + byte[] bytes = framed.toByteArray(); + int pos = skipSendHeader(bytes); + pos = assertTag(bytes, pos, "DATA"); + assertEquals(1, Wire.readLe32(bytes, pos)); pos += 4; + assertEquals(0x42, bytes[pos]); pos += 1; + pos = assertTag(bytes, pos, "DONE"); + assertEquals(MTIME, Wire.readLe32(bytes, pos)); pos += 4; + assertEquals(bytes.length, pos); + } + + @Test + public void payloadLargerThanChunkSplits() throws Exception { + int n = Sync.CHUNK + 1; + byte[] payload = new byte[n]; + for (int i = 0; i < n; i++) payload[i] = (byte) i; + + ByteArrayOutputStream framed = new ByteArrayOutputStream(); + long total = Sync.push(new ByteArrayInputStream(payload), + framed, okayResponse(), PATH, MODE, MTIME); + assertEquals(n, total); + + byte[] bytes = framed.toByteArray(); + int pos = skipSendHeader(bytes); + + // First DATA = full chunk + pos = assertTag(bytes, pos, "DATA"); + assertEquals(Sync.CHUNK, Wire.readLe32(bytes, pos)); pos += 4; + pos += Sync.CHUNK; + + // Second DATA = the remaining one byte + pos = assertTag(bytes, pos, "DATA"); + assertEquals(1, Wire.readLe32(bytes, pos)); pos += 4; + pos += 1; + + pos = assertTag(bytes, pos, "DONE"); + assertEquals(MTIME, Wire.readLe32(bytes, pos)); pos += 4; + assertEquals(bytes.length, pos); + } + + @Test + public void failResponseRaises() { + ByteArrayOutputStream framed = new ByteArrayOutputStream(); + ByteArrayInputStream resp = failResponse("disk full"); + try { + Sync.push(new ByteArrayInputStream(new byte[]{1, 2, 3}), framed, resp, + PATH, MODE, MTIME); + fail("expected IOException"); + } catch (IOException e) { + assertTrue(e.getMessage(), e.getMessage().contains("disk full")); + assertTrue(e.getMessage(), e.getMessage().startsWith("sync FAIL")); + } + } + + private static ByteArrayInputStream okayResponse() { + byte[] r = new byte[8]; + r[0] = 'O'; r[1] = 'K'; r[2] = 'A'; r[3] = 'Y'; + // length 0 + return new ByteArrayInputStream(r); + } + + private static ByteArrayInputStream failResponse(String msg) { + byte[] mb = msg.getBytes(StandardCharsets.UTF_8); + byte[] r = new byte[8 + mb.length]; + r[0] = 'F'; r[1] = 'A'; r[2] = 'I'; r[3] = 'L'; + Wire.writeLe32(r, 4, mb.length); + System.arraycopy(mb, 0, r, 8, mb.length); + return new ByteArrayInputStream(r); + } + + private static int assertTag(byte[] bytes, int pos, String tag) { + for (int i = 0; i < 4; i++) { + assertEquals("tag pos=" + pos + " idx=" + i, + (byte) tag.charAt(i), bytes[pos + i]); + } + return pos + 4; + } + + private static int skipSendHeader(byte[] bytes) { + // SEND + le32(len) + header + int pos = 4; + int len = Wire.readLe32(bytes, pos); + return pos + 4 + len; + } +} diff --git a/app/src/test/java/invalid/lena/scrcpy/VideoStreamTest.java b/app/src/test/java/invalid/lena/scrcpy/VideoStreamTest.java new file mode 100644 index 0000000..9d132f6 --- /dev/null +++ b/app/src/test/java/invalid/lena/scrcpy/VideoStreamTest.java @@ -0,0 +1,245 @@ +package invalid.lena.scrcpy; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class VideoStreamTest { + + // ---- recording sink ---- + + private static final class RecordingFrames implements VideoFrames { + static final class Cfg { final int fourcc, w, h; Cfg(int f,int w,int h){this.fourcc=f;this.w=w;this.h=h;} } + static final class Feed { final byte[] data; final long ptsUs; final boolean isCfg; + Feed(byte[] d,long p,boolean c){data=d;ptsUs=p;isCfg=c;} } + + final List<Cfg> configs = new ArrayList<>(); + final List<Cfg> reconfigs = new ArrayList<>(); + final List<Feed> feeds = new ArrayList<>(); + int releases = 0; + + @Override public void configure(int f, int w, int h) { configs.add(new Cfg(f, w, h)); } + @Override public void reconfigure(int f, int w, int h) { reconfigs.add(new Cfg(f, w, h)); } + @Override public void feed(byte[] d, long pts, boolean c){ feeds.add(new Feed(d, pts, c)); } + @Override public void release() { releases++; } + } + + // ---- byte builders ---- + + private static byte[] fourcc(int v) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + new DataOutputStream(bos).writeInt(v); + return bos.toByteArray(); + } + + private static byte[] sessionMeta(int w, int h, boolean clientResize) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bos); + out.writeInt(0x80000000 | (clientResize ? 1 : 0)); + out.writeInt(w); + out.writeInt(h); + return bos.toByteArray(); + } + + private static byte[] frame(long ptsAndFlags, byte[] payload) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bos); + out.writeLong(ptsAndFlags); + out.writeInt(payload.length); + out.write(payload); + return bos.toByteArray(); + } + + private static byte[] cat(byte[]... parts) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + for (byte[] p : parts) bos.write(p); + return bos.toByteArray(); + } + + private static void runUntilEof(VideoStream s) { + s.run(); // returns when the InputStream hits EOF (IOException) + } + + // ---- tests ---- + + @Test + public void happyPath_configureCsdKeyframeDelta() throws Exception { + byte[] csd = new byte[]{0x67, 0x42, (byte)0xe0, 0x1e}; // bogus SPS-ish + byte[] keyframe = new byte[]{1, 2, 3, 4, 5}; + byte[] delta = new byte[]{6, 7, 8}; + + long PTS_CFG = 1L << 62; // CONFIG + long PTS_KEY = (1L << 61) | 1_000_000L; // KEYFRAME, pts=1s + long PTS_DELT = 2_000_000L; // pts=2s + + byte[] bytes = cat( + fourcc(Wire.CODEC_H264), + sessionMeta(1080, 2400, false), + frame(PTS_CFG, csd), + frame(PTS_KEY, keyframe), + frame(PTS_DELT, delta)); + + RecordingFrames sink = new RecordingFrames(); + VideoStream s = new VideoStream(new ByteArrayInputStream(bytes), sink, + (w, h) -> {}); + runUntilEof(s); + + assertEquals(1, sink.configs.size()); + assertEquals(Wire.CODEC_H264, sink.configs.get(0).fourcc); + assertEquals(1080, sink.configs.get(0).w); + assertEquals(2400, sink.configs.get(0).h); + + assertEquals(3, sink.feeds.size()); + assertTrue("first is config", sink.feeds.get(0).isCfg); + assertEquals(0L, sink.feeds.get(0).ptsUs); // CONFIG strips pts to 0 + assertEquals(4, sink.feeds.get(0).data.length); + + // keyframe: keyframe flag is stripped from ptsUs + assertEquals(1_000_000L, sink.feeds.get(1).ptsUs); + assertEquals(5, sink.feeds.get(1).data.length); + + assertEquals(2_000_000L, sink.feeds.get(2).ptsUs); + } + + @Test + public void sessionMetaResizeTriggersReconfigure() throws Exception { + byte[] bytes = cat( + fourcc(Wire.CODEC_H264), + sessionMeta(1080, 2400, false), + frame(1L << 62, new byte[]{1}), // config + sessionMeta(720, 1600, true), // resize + frame(1L << 61, new byte[]{2})); // keyframe at new size + + RecordingFrames sink = new RecordingFrames(); + new VideoStream(new ByteArrayInputStream(bytes), sink, null).run(); + + assertEquals(1, sink.configs.size()); + assertEquals(1, sink.reconfigs.size()); + assertEquals(720, sink.reconfigs.get(0).w); + assertEquals(1600, sink.reconfigs.get(0).h); + } + + @Test + public void sizeListenerFiresOnEachSessionMeta() throws Exception { + byte[] bytes = cat( + fourcc(Wire.CODEC_H264), + sessionMeta(1080, 2400, false), + frame(1L << 62, new byte[]{1}), + sessionMeta(720, 1600, false), + frame(1L << 61, new byte[]{2})); + + List<int[]> sizes = new ArrayList<>(); + RecordingFrames sink = new RecordingFrames(); + new VideoStream(new ByteArrayInputStream(bytes), sink, + (w, h) -> sizes.add(new int[]{w, h})).run(); + + assertEquals(2, sizes.size()); + assertEquals(1080, sizes.get(0)[0]); + assertEquals(720, sizes.get(1)[0]); + } + + @Test + public void disabledCodecExits() throws Exception { + byte[] bytes = fourcc(0); + RecordingFrames sink = new RecordingFrames(); + new VideoStream(new ByteArrayInputStream(bytes), sink, null).run(); + assertEquals(0, sink.configs.size()); + assertEquals(0, sink.feeds.size()); + } + + @Test + public void frameBeforeSessionMetaIsRejected() throws Exception { + // A frame header arriving without a preceding session-meta should + // raise - the stream is unconfigured. + byte[] bytes = cat( + fourcc(Wire.CODEC_H264), + frame(1L << 62, new byte[]{1, 2, 3})); // config-flag set, but no meta first + RecordingFrames sink = new RecordingFrames(); + new VideoStream(new ByteArrayInputStream(bytes), sink, null).run(); + // The loop should have aborted without feeding anything. + assertEquals(0, sink.feeds.size()); + assertEquals(0, sink.configs.size()); + } + + @Test + public void recorderTapSeesMetaAndFramesWithFlags() throws Exception { + byte[] csd = new byte[]{0x67, 0x42, (byte)0xe0, 0x1e}; + byte[] keyframe = new byte[]{10, 11, 12}; + byte[] delta = new byte[]{20, 21}; + long PTS_CFG = 1L << 62; + long PTS_KEY = (1L << 61) | 1_000_000L; + long PTS_DELT = 2_000_000L; + + byte[] bytes = cat( + fourcc(Wire.CODEC_H264), + sessionMeta(800, 600, false), + frame(PTS_CFG, csd), + frame(PTS_KEY, keyframe), + frame(PTS_DELT, delta)); + + RecordingFrames sink = new RecordingFrames(); + RecordingRecorder rec = new RecordingRecorder(); + VideoStream s = new VideoStream(new ByteArrayInputStream(bytes), sink, null); + s.setRecorder(rec); + s.run(); + + assertEquals(1, rec.metas.size()); + assertEquals(800, rec.metas.get(0)[1]); + assertEquals(600, rec.metas.get(0)[2]); + + assertEquals(3, rec.frames.size()); + assertTrue("cfg frame", rec.frames.get(0).isConfig); + assertTrue("key frame", rec.frames.get(1).isKeyframe); + assertTrue("delta is not config or key", + !rec.frames.get(2).isConfig && !rec.frames.get(2).isKeyframe); + assertEquals(csd.length, rec.frames.get(0).data.length); + assertEquals(keyframe.length, rec.frames.get(1).data.length); + assertEquals(delta.length, rec.frames.get(2).data.length); + } + + private static final class RecordingRecorder implements VideoRecorder { + static final class FrameRec { + final byte[] data; final long ptsUs; + final boolean isConfig, isKeyframe; + FrameRec(byte[] d, long p, boolean c, boolean k) { + data = d; ptsUs = p; isConfig = c; isKeyframe = k; + } + } + final List<int[]> metas = new ArrayList<>(); + final List<FrameRec> frames = new ArrayList<>(); + int closes = 0; + @Override public void onMeta(int f, int w, int h) { metas.add(new int[]{f, w, h}); } + @Override public void onFrame(byte[] d, long p, boolean c, boolean k) { + frames.add(new FrameRec(d, p, c, k)); + } + @Override public void close() { closes++; } + } + + @Test + public void payloadIntegrity() throws Exception { + byte[] payload = new byte[1024]; + for (int i = 0; i < payload.length; i++) payload[i] = (byte) (i & 0xff); + byte[] bytes = cat( + fourcc(Wire.CODEC_H264), + sessionMeta(640, 480, false), + frame(1L << 62, payload)); + + RecordingFrames sink = new RecordingFrames(); + new VideoStream(new ByteArrayInputStream(bytes), sink, null).run(); + assertNotNull(sink.feeds.isEmpty() ? null : sink.feeds.get(0)); + byte[] got = sink.feeds.get(0).data; + assertEquals(payload.length, got.length); + for (int i = 0; i < payload.length; i++) { + assertEquals("byte " + i, payload[i], got[i]); + } + } +} diff --git a/app/src/test/java/invalid/lena/scrcpy/WireTest.java b/app/src/test/java/invalid/lena/scrcpy/WireTest.java new file mode 100644 index 0000000..9cee6c5 --- /dev/null +++ b/app/src/test/java/invalid/lena/scrcpy/WireTest.java @@ -0,0 +1,94 @@ +package invalid.lena.scrcpy; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.EOFException; + +public class WireTest { + + @Test + public void be32RoundTrip() { + byte[] b = new byte[4]; + int[] cases = {0, 1, -1, 0x7fffffff, 0x80000000, 0x12345678}; + for (int v : cases) { + Wire.writeBe32(b, 0, v); + assertEquals(v, Wire.readBe32(b, 0)); + } + } + + @Test + public void be32Layout() { + byte[] b = new byte[4]; + Wire.writeBe32(b, 0, 0x12345678); + assertArrayEquals(new byte[]{0x12, 0x34, 0x56, 0x78}, b); + } + + @Test + public void be64RoundTrip() { + byte[] b = new byte[8]; + long[] cases = {0L, 1L, -1L, Long.MAX_VALUE, Long.MIN_VALUE, 0x0123456789abcdefL}; + for (long v : cases) { + Wire.writeBe64(b, 0, v); + assertEquals(v, Wire.readBe64(b, 0)); + } + } + + @Test + public void le32RoundTrip() { + byte[] b = new byte[4]; + int[] cases = {0, 1, -1, 0x7fffffff, 0x80000000, 0x12345678}; + for (int v : cases) { + Wire.writeLe32(b, 0, v); + assertEquals(v, Wire.readLe32(b, 0)); + } + } + + @Test + public void le32Layout() { + byte[] b = new byte[4]; + Wire.writeLe32(b, 0, 0x12345678); + assertArrayEquals(new byte[]{0x78, 0x56, 0x34, 0x12}, b); + } + + @Test + public void readFullyShortStreamThrows() throws Exception { + ByteArrayInputStream in = new ByteArrayInputStream(new byte[3]); + byte[] buf = new byte[8]; + try { + Wire.readFully(in, buf); + fail("expected EOFException"); + } catch (EOFException ignored) { + // expected + } + } + + @Test + public void fourccConstantsMatchScrcpy() { + // Big-endian uint32 of the 4-char ASCII tag, NUL-padded on the LEFT + // for short names (raw, aac) - mirrors AudioCodec / VideoCodec. + assertEquals(0x68_32_36_34, Wire.CODEC_H264); // "h264" + assertEquals(0x68_32_36_35, Wire.CODEC_H265); // "h265" + assertEquals(0x61_76_30_31, Wire.CODEC_AV1); // "av01" + assertEquals(0x6f_70_75_73, Wire.CODEC_OPUS); // "opus" + assertEquals(0x66_6c_61_63, Wire.CODEC_FLAC); // "flac" + assertEquals(0x00_61_61_63, Wire.CODEC_AAC); // "\0aac" + assertEquals(0x00_72_61_77, Wire.CODEC_RAW); // "\0raw" + } + + @Test + public void fourccName() { + assertEquals("h264", Wire.fourccName(Wire.CODEC_H264)); + assertEquals("opus", Wire.fourccName(Wire.CODEC_OPUS)); + } + + @Test + public void fourccNameTrimsLeadingNuls() { + assertEquals("raw", Wire.fourccName(Wire.CODEC_RAW)); + assertEquals("aac", Wire.fourccName(Wire.CODEC_AAC)); + } +} |