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. --- .../java/invalid/lena/scrcpy/VideoStreamTest.java | 245 +++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 app/src/test/java/invalid/lena/scrcpy/VideoStreamTest.java (limited to 'app/src/test/java/invalid/lena/scrcpy/VideoStreamTest.java') 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 configs = new ArrayList<>(); + final List reconfigs = new ArrayList<>(); + final List 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 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 metas = new ArrayList<>(); + final List 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]); + } + } +} -- cgit v1.2.3