aboutsummaryrefslogtreecommitdiff
path: root/app/src/test/java/invalid/lena/scrcpy
diff options
context:
space:
mode:
authorLena <lena@omega>2026-01-01 00:00:00 +0000
committerLena <lena@omega>2026-06-24 22:14:50 +0300
commiteb0c8951196c637e44daf3c0617b131b997d5d2c (patch)
tree2f83b6a41cee745467e53fc401942b82cea286ea /app/src/test/java/invalid/lena/scrcpy
downloadscrcpy-android-0.1.tar.gz
scrcpy-android: mirror an Android device over wireless ADB0.1
Native Java app for Android 12+ that mirrors another Android device over wireless ADB, forwarding video, audio, touch input, and clipboard. Bundles a pinned scrcpy-server.jar and the vendored libadb-android stack. Supports h264/h265/av1 video and raw/opus audio with in-app codec selection. No NDK, no Kotlin. Includes JVM unit tests and a Docker-based emulator e2e rig.
Diffstat (limited to 'app/src/test/java/invalid/lena/scrcpy')
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/AtomicFilesTest.java54
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/AudioStreamTest.java123
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/ControlMessagesTest.java65
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/ControlStreamTest.java152
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/DevicesTest.java64
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/SyncTest.java137
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/VideoStreamTest.java245
-rw-r--r--app/src/test/java/invalid/lena/scrcpy/WireTest.java94
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));
+ }
+}