From eb0c8951196c637e44daf3c0617b131b997d5d2c Mon Sep 17 00:00:00 2001 From: Lena Date: Thu, 1 Jan 2026 00:00:00 +0000 Subject: scrcpy-android: mirror an Android device over wireless ADB 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. --- .../invalid/lena/scrcpy/ControlStreamTest.java | 152 +++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 app/src/test/java/invalid/lena/scrcpy/ControlStreamTest.java (limited to 'app/src/test/java/invalid/lena/scrcpy/ControlStreamTest.java') 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 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]); + } +} -- cgit v1.2.3