aboutsummaryrefslogtreecommitdiff
path: root/app/src
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
downloadscrcpy-android-eb0c8951196c637e44daf3c0617b131b997d5d2c.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')
-rw-r--r--app/src/debug/res/layout/mirror.xml93
-rw-r--r--app/src/main/AndroidManifest.xml49
-rw-r--r--app/src/main/assets/scrcpy-server.sha2561
-rw-r--r--app/src/main/assets/scrcpy-server.version1
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Adb.java210
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/App.java15
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java40
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/AudioFrames.java18
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/AudioSink.java215
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/AudioStream.java93
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/ControlMessages.java72
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/ControlStream.java154
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Controller.java173
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Crashlog.java48
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Devices.java126
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Log.java26
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Main.java150
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Mirror.java471
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/MuxRecorder.java225
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Server.java304
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Session.java318
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Sessions.java75
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Settings.java93
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java110
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Sync.java78
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/VideoFrames.java17
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/VideoRecorder.java19
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/VideoSink.java244
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/VideoStream.java156
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Wire.java99
-rw-r--r--app/src/main/res/drawable/bg_card.xml8
-rw-r--r--app/src/main/res/drawable/bg_field.xml18
-rw-r--r--app/src/main/res/drawable/bg_status_pill.xml7
-rw-r--r--app/src/main/res/drawable/btn_primary.xml11
-rw-r--r--app/src/main/res/drawable/btn_secondary.xml12
-rw-r--r--app/src/main/res/drawable/ic_device.xml14
-rw-r--r--app/src/main/res/drawable/ic_launcher_background.xml7
-rw-r--r--app/src/main/res/drawable/ic_launcher_foreground.xml52
-rw-r--r--app/src/main/res/drawable/ic_notification.xml21
-rw-r--r--app/src/main/res/drawable/ic_settings.xml12
-rw-r--r--app/src/main/res/layout/device_row.xml28
-rw-r--r--app/src/main/res/layout/main.xml181
-rw-r--r--app/src/main/res/layout/mirror.xml67
-rw-r--r--app/src/main/res/layout/settings.xml294
-rw-r--r--app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml6
-rw-r--r--app/src/main/res/values/colors.xml16
-rw-r--r--app/src/main/res/values/dimens.xml17
-rw-r--r--app/src/main/res/values/ids.xml10
-rw-r--r--app/src/main/res/values/strings.xml36
-rw-r--r--app/src/main/res/values/styles.xml73
-rw-r--r--app/src/main/res/values/themes.xml32
-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
59 files changed, 5549 insertions, 0 deletions
diff --git a/app/src/debug/res/layout/mirror.xml b/app/src/debug/res/layout/mirror.xml
new file mode 100644
index 0000000..5d0e845
--- /dev/null
+++ b/app/src/debug/res/layout/mirror.xml
@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Debug Mirror layout: TextureView, the same top status bar as
+ production, plus a bottom-left stats overlay that screencap can
+ capture (target host, video/audio/texture-update counts, last
+ forwarded event). The TextureView path is used because emulators
+ don't composite SurfaceView's hardware layer into screencap. -->
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="#000000">
+
+ <TextureView
+ android:id="@+id/surface"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_gravity="center"/>
+
+ <LinearLayout
+ android:id="@+id/status_bar"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="top"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:padding="8dp"
+ android:background="#a0000000">
+
+ <TextView
+ android:id="@+id/status_text"
+ android:layout_width="0dp"
+ android:layout_weight="1"
+ android:layout_height="wrap_content"
+ android:textColor="#ffffff"
+ android:textSize="14sp"
+ android:fontFamily="monospace"
+ android:singleLine="true"
+ android:ellipsize="end"
+ android:text="@string/connecting"/>
+
+ <Button
+ android:id="@+id/record"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginEnd="8dp"
+ android:text="@string/record"/>
+
+ <Button
+ android:id="@+id/reconnect"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/reconnect"
+ android:visibility="gone"/>
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="bottom|start"
+ android:orientation="vertical"
+ android:padding="16dp"
+ android:background="#a0000000">
+
+ <TextView
+ android:id="@+id/overlay_target"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textColor="#ffffff"
+ android:textSize="16sp"
+ android:fontFamily="monospace"
+ android:text="target: ..."/>
+
+ <TextView
+ android:id="@+id/overlay_stats"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textColor="#aef0a0"
+ android:textSize="16sp"
+ android:fontFamily="monospace"
+ android:text="v=0 a=0"/>
+
+ <TextView
+ android:id="@+id/overlay_event"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textColor="#f0c060"
+ android:textSize="16sp"
+ android:fontFamily="monospace"
+ android:text="event: (idle)"/>
+
+ </LinearLayout>
+
+</FrameLayout>
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..3e1e4f3
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <uses-permission android:name="android.permission.INTERNET"/>
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
+
+ <application
+ android:name=".App"
+ android:label="@string/app_name"
+ android:icon="@mipmap/ic_launcher"
+ android:roundIcon="@mipmap/ic_launcher"
+ android:theme="@style/Theme.Scrcpy"
+ android:allowBackup="false"
+ android:supportsRtl="false">
+
+ <activity
+ android:name=".Main"
+ android:exported="true"
+ android:theme="@style/Theme.Scrcpy.NoActionBar">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN"/>
+ <category android:name="android.intent.category.LAUNCHER"/>
+ </intent-filter>
+ </activity>
+
+ <activity
+ android:name=".Mirror"
+ android:exported="false"
+ android:configChanges="orientation|screenSize|keyboardHidden"
+ android:screenOrientation="unspecified"
+ android:hardwareAccelerated="true"
+ android:theme="@style/Theme.Scrcpy.Fullscreen"/>
+
+ <activity
+ android:name=".SettingsActivity"
+ android:exported="false"
+ android:label="@string/settings"/>
+
+ <service
+ android:name=".Sessions"
+ android:exported="false"
+ android:foregroundServiceType="dataSync"/>
+
+ </application>
+
+</manifest>
diff --git a/app/src/main/assets/scrcpy-server.sha256 b/app/src/main/assets/scrcpy-server.sha256
new file mode 100644
index 0000000..376a42f
--- /dev/null
+++ b/app/src/main/assets/scrcpy-server.sha256
@@ -0,0 +1 @@
+84924bd564a1eb6089c872c7521f968058977f91f5ff02514a8c74aff3210f3a
diff --git a/app/src/main/assets/scrcpy-server.version b/app/src/main/assets/scrcpy-server.version
new file mode 100644
index 0000000..5186d07
--- /dev/null
+++ b/app/src/main/assets/scrcpy-server.version
@@ -0,0 +1 @@
+4.0
diff --git a/app/src/main/java/invalid/lena/scrcpy/Adb.java b/app/src/main/java/invalid/lena/scrcpy/Adb.java
new file mode 100644
index 0000000..2909156
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Adb.java
@@ -0,0 +1,210 @@
+package invalid.lena.scrcpy;
+
+import android.content.Context;
+
+import org.bouncycastle.asn1.ASN1EncodableVector;
+import org.bouncycastle.asn1.ASN1Encoding;
+import org.bouncycastle.asn1.ASN1Integer;
+import org.bouncycastle.asn1.DERBitString;
+import org.bouncycastle.asn1.DERSequence;
+import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
+import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
+import org.bouncycastle.asn1.x509.TBSCertificate;
+import org.bouncycastle.asn1.x509.Time;
+import org.bouncycastle.asn1.x509.V3TBSCertificateGenerator;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.math.BigInteger;
+import java.nio.file.Files;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PrivateKey;
+import java.security.SecureRandom;
+import java.security.Signature;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateFactory;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.util.Date;
+import java.util.concurrent.TimeUnit;
+
+import io.github.muntashirakon.adb.AbsAdbConnectionManager;
+import io.github.muntashirakon.adb.AdbStream;
+import io.github.muntashirakon.adb.LocalServices;
+
+// Thin facade over libadb-android. One instance per process: pairs once,
+// connects per session, then exposes typed openers for the three streams
+// the rest of the app cares about (shell, sync, localabstract).
+//
+// The keypair lives in filesDir/{adbkey,adbcert}: PKCS#8 DER for the key,
+// X.509 DER for the cert. Software-backed RSA - AndroidKeyStore is unusable
+// here because libadb-android's auth path uses raw RSA/ECB/NoPadding which
+// hardware-backed keys refuse.
+public final class Adb extends AbsAdbConnectionManager {
+
+ private static final String DEVICE_NAME = "scrcpy-android";
+ private static final String KEY_FILE = "adbkey";
+ private static final String CERT_FILE = "adbcert";
+
+ // Per-operation timeout for adb protocol calls (pair/connect/openStream).
+ // libadb-android defaults to Long.MAX_VALUE; a hung target would block
+ // forever otherwise.
+ private static final long OP_TIMEOUT_MS = 15_000L;
+
+ // libadb-android's mApi is documented as "the target's API for protocol
+ // negotiation". We don't know the target's API at construction time;
+ // pinning to the current Android max means the protocol stays at its
+ // latest version, which is what wireless-debugging Android-11+ targets
+ // expect. Bump when newer protocol versions ship.
+ private static final int TARGET_API_HINT = android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE;
+
+ // Singleton: Main and Mirror both want an Adb; building two would
+ // race on the on-disk keypair and waste a RSA generation. Lazily
+ // initialised on the FIRST caller, which should be off the UI thread.
+ private static volatile Adb instance;
+
+ public static Adb getInstance(Context ctx) throws Exception {
+ Adb local = instance;
+ if (local != null) return local;
+ synchronized (Adb.class) {
+ if (instance == null) instance = new Adb(ctx.getApplicationContext());
+ return instance;
+ }
+ }
+
+ private final PrivateKey privateKey;
+ private final Certificate certificate;
+
+ private Adb(Context ctx) throws Exception {
+ setApi(TARGET_API_HINT);
+ setTimeout(OP_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+ File keyFile = new File(ctx.getFilesDir(), KEY_FILE);
+ File certFile = new File(ctx.getFilesDir(), CERT_FILE);
+ if (keyFile.exists() && certFile.exists()) {
+ privateKey = loadKey(keyFile);
+ certificate = loadCert(certFile);
+ Log.i("adb: loaded keypair from %s", keyFile);
+ } else {
+ KeyPair kp = generateRsa();
+ privateKey = kp.getPrivate();
+ certificate = selfSignedCert(kp);
+ saveKey(keyFile, privateKey);
+ saveCert(certFile, certificate);
+ Log.i("adb: generated new keypair at %s", keyFile);
+ }
+ }
+
+ @Override protected PrivateKey getPrivateKey() { return privateKey; }
+ @Override protected Certificate getCertificate() { return certificate; }
+ @Override protected String getDeviceName() { return DEVICE_NAME; }
+
+ // ---- typed openers ----
+
+ public AdbStream openAbstract(String name) throws IOException, InterruptedException {
+ return openStream(LocalServices.LOCAL_UNIX_SOCKET_ABSTRACT, name);
+ }
+
+ public AdbStream openShell(String cmd) throws IOException, InterruptedException {
+ // Bypass LocalServices.getDestination(SHELL, args) - it wraps any
+ // arg containing a space in literal double quotes, and adbd then
+ // tries to exec `"the whole quoted thing"` as a single filename.
+ // Build the destination ourselves so the cmd reaches sh -c
+ // unmolested.
+ return openStream("shell:" + cmd);
+ }
+
+ public AdbStream openSync() throws IOException, InterruptedException {
+ return openStream(LocalServices.SYNC);
+ }
+
+ // ---- key + cert I/O ----
+
+ private static KeyPair generateRsa() throws Exception {
+ KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
+ gen.initialize(2048, new SecureRandom());
+ return gen.generateKeyPair();
+ }
+
+ private static PrivateKey loadKey(File f) throws Exception {
+ byte[] data = Files.readAllBytes(f.toPath());
+ return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(data));
+ }
+
+ private static Certificate loadCert(File f) throws Exception {
+ try (InputStream in = new FileInputStream(f)) {
+ return CertificateFactory.getInstance("X.509").generateCertificate(in);
+ }
+ }
+
+ private static void saveKey(File f, PrivateKey k) throws IOException {
+ AtomicFiles.write(f, k.getEncoded());
+ }
+
+ private static void saveCert(File f, Certificate c) throws Exception {
+ AtomicFiles.write(f, c.getEncoded());
+ }
+
+ // Build a minimal self-signed X.509 certificate over the keypair.
+ //
+ // ASN.1 layout (RFC 5280 sec. 4.1):
+ //
+ // Certificate ::= SEQUENCE {
+ // tbsCertificate TBSCertificate,
+ // signatureAlgorithm AlgorithmIdentifier, -- sha256WithRSAEnc
+ // signature BIT STRING -- RSA over tbs
+ // }
+ //
+ // TBSCertificate ::= SEQUENCE {
+ // version [0] EXPLICIT v3,
+ // serialNumber INTEGER 1,
+ // signatureAlgorithm AlgorithmIdentifier,
+ // issuer = subject Name (CN=scrcpy-android),
+ // validity { notBefore, notAfter },
+ // subjectPublicKeyInfo SubjectPublicKeyInfo
+ // }
+ //
+ // ADB validates the peer by the raw RSA public-key fingerprint in the
+ // SPKI bits, not by the DN or signature, so the surrounding cert is
+ // cosmetic - but a valid X.509 wrapper is still required for the TLS
+ // handshake that wireless-debugging uses post-pairing.
+ private static Certificate selfSignedCert(KeyPair kp) throws Exception {
+ long now = System.currentTimeMillis();
+ Date notBefore = new Date(now - 60_000L);
+ Date notAfter = new Date(now + 50L * 365 * 24 * 3600 * 1000L);
+ X500Name dn = new X500Name("CN=" + DEVICE_NAME);
+ AlgorithmIdentifier sigAlg =
+ new AlgorithmIdentifier(PKCSObjectIdentifiers.sha256WithRSAEncryption);
+
+ V3TBSCertificateGenerator g = new V3TBSCertificateGenerator();
+ g.setSerialNumber(new ASN1Integer(BigInteger.ONE));
+ g.setSignature(sigAlg);
+ g.setIssuer(dn);
+ g.setSubject(dn);
+ g.setStartDate(new Time(notBefore));
+ g.setEndDate(new Time(notAfter));
+ g.setSubjectPublicKeyInfo(
+ SubjectPublicKeyInfo.getInstance(kp.getPublic().getEncoded()));
+
+ TBSCertificate tbs = g.generateTBSCertificate();
+
+ Signature s = Signature.getInstance("SHA256withRSA");
+ s.initSign(kp.getPrivate());
+ s.update(tbs.getEncoded(ASN1Encoding.DER));
+ byte[] sig = s.sign();
+
+ ASN1EncodableVector v = new ASN1EncodableVector();
+ v.add(tbs);
+ v.add(sigAlg);
+ v.add(new DERBitString(sig));
+ byte[] certDer = new DERSequence(v).getEncoded(ASN1Encoding.DER);
+
+ return CertificateFactory.getInstance("X.509")
+ .generateCertificate(new ByteArrayInputStream(certDer));
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/App.java b/app/src/main/java/invalid/lena/scrcpy/App.java
new file mode 100644
index 0000000..f28ec9d
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/App.java
@@ -0,0 +1,15 @@
+package invalid.lena.scrcpy;
+
+import android.app.Application;
+
+// Process-wide bootstrap. Only job today is to install the crash
+// logger before any of our code runs. Add other process-scoped init
+// here if it appears, but resist filling this with statics.
+public final class App extends Application {
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ Crashlog.install(this);
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java b/app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java
new file mode 100644
index 0000000..6f755b6
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java
@@ -0,0 +1,40 @@
+package invalid.lena.scrcpy;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+
+// Crash-atomic whole-file write: stage the bytes into a sibling temp file,
+// fsync it, then rename it over the destination. The rename is the only
+// mutation a concurrent reader can observe, so a reader sees either the old
+// file or the new file in full, never a truncated mix. A crash mid-write
+// leaves at most a stale ".tmp", never a damaged destination.
+//
+// Deliberately no fsync of the parent directory: the rename itself may be
+// lost on power failure (the old content survives intact). Callers store
+// re-creatable state, so atomicity matters here and durability does not.
+//
+// android-free (java.io/java.nio only) so it is unit-testable on the JVM.
+final class AtomicFiles {
+
+ private AtomicFiles() {}
+
+ static void write(File dest, byte[] data) throws IOException {
+ File parent = dest.getAbsoluteFile().getParentFile();
+ File tmp = new File(parent, dest.getName() + ".tmp");
+ try (FileOutputStream os = new FileOutputStream(tmp)) {
+ os.write(data);
+ os.flush();
+ os.getFD().sync();
+ }
+ try {
+ Files.move(tmp.toPath(), dest.toPath(),
+ StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ } catch (IOException e) {
+ tmp.delete();
+ throw e;
+ }
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/AudioFrames.java b/app/src/main/java/invalid/lena/scrcpy/AudioFrames.java
new file mode 100644
index 0000000..da0bb0a
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/AudioFrames.java
@@ -0,0 +1,18 @@
+package invalid.lena.scrcpy;
+
+// Sink for parsed audio frames. AudioSink is the only production
+// implementation; tests use a recording stub. Kept android-free.
+//
+// start(fourcc) tells the sink which wire codec it should expect.
+// For raw payloads are interleaved s16le PCM. For opus the first
+// frame has isConfig=true and payload is the OpusHead (the server
+// pre-strips the AOPUSHDR/AOPUSDLY/AOPUSPRL container); subsequent
+// frames are opus packets.
+public interface AudioFrames {
+
+ void start(int fourcc);
+
+ void feed(byte[] data, int off, int len, boolean isConfig);
+
+ void release();
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/AudioSink.java b/app/src/main/java/invalid/lena/scrcpy/AudioSink.java
new file mode 100644
index 0000000..2b3e32d
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/AudioSink.java
@@ -0,0 +1,215 @@
+package invalid.lena.scrcpy;
+
+import android.media.AudioAttributes;
+import android.media.AudioFormat;
+import android.media.AudioManager;
+import android.media.AudioTrack;
+import android.media.MediaCodec;
+import android.media.MediaFormat;
+import android.os.Handler;
+import android.os.HandlerThread;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+// Audio output: AudioTrack writing 48 kHz stereo 16-bit PCM. The
+// upstream feed is either raw PCM (passthrough) or Opus packets
+// (decoded through MediaCodec into PCM first).
+public final class AudioSink implements AudioFrames {
+
+ private static final int SAMPLE_RATE = 48_000;
+ private static final int CHANNEL_OUT = AudioFormat.CHANNEL_OUT_STEREO;
+ private static final int ENCODING = AudioFormat.ENCODING_PCM_16BIT;
+
+ // Defaults documented at <https://developer.android.com/reference/android/media/MediaCodec#CSD>.
+ private static final long DEFAULT_PRE_ROLL_NS = 80_000_000L;
+
+ private AudioTrack track;
+ private MediaCodec opusCodec;
+ private HandlerThread opusThread;
+ private Handler opusHandler;
+ private boolean opusConfigured;
+ private int fourcc;
+
+ private volatile boolean released;
+ public volatile long frames; // public read for the status overlay
+ private long droppedBytes;
+ private long lastDropLogMs;
+
+ @Override
+ public void start(int fourcc) {
+ this.fourcc = fourcc;
+
+ int minBuf = AudioTrack.getMinBufferSize(SAMPLE_RATE, CHANNEL_OUT, ENCODING);
+ if (minBuf <= 0) throw new IllegalStateException("AudioTrack.getMinBufferSize=" + minBuf);
+ int bufSize = Math.max(minBuf, 32 * 1024);
+
+ AudioAttributes attrs = new AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
+ .build();
+ AudioFormat fmt = new AudioFormat.Builder()
+ .setSampleRate(SAMPLE_RATE)
+ .setChannelMask(CHANNEL_OUT)
+ .setEncoding(ENCODING)
+ .build();
+
+ track = new AudioTrack(
+ attrs, fmt, bufSize, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE);
+ track.play();
+ Log.i("audio sink: AudioTrack started sr=%d ch=2 buf=%d", SAMPLE_RATE, bufSize);
+
+ if (fourcc == Wire.CODEC_OPUS) {
+ opusThread = new HandlerThread("audio-mc");
+ opusThread.start();
+ opusHandler = new Handler(opusThread.getLooper());
+ // Codec is created here, but only configured once the first
+ // FLAG_CONFIG frame arrives with the OpusHead bytes.
+ try {
+ opusCodec = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS);
+ } catch (IOException e) {
+ throw new IllegalStateException("audio sink: no opus decoder", e);
+ }
+ opusCodec.setCallback(new MediaCodec.Callback() {
+ @Override public void onInputBufferAvailable(MediaCodec mc, int idx) {
+ // Frames are queued synchronously from feed(); we
+ // don't pull on this callback. This handler exists
+ // so MediaCodec's async machinery is wired up.
+ }
+ @Override public void onOutputBufferAvailable(MediaCodec mc, int idx,
+ MediaCodec.BufferInfo info) {
+ try {
+ ByteBuffer out = mc.getOutputBuffer(idx);
+ if (out != null && info.size > 0 && !released) {
+ byte[] pcm = new byte[info.size];
+ out.position(info.offset);
+ out.limit(info.offset + info.size);
+ out.get(pcm);
+ writePcm(pcm, 0, pcm.length);
+ }
+ } catch (IllegalStateException ignored) {
+ } finally {
+ try { mc.releaseOutputBuffer(idx, false); }
+ catch (IllegalStateException ignored) {}
+ }
+ }
+ @Override public void onError(MediaCodec mc, MediaCodec.CodecException e) {
+ Log.e(e, "audio sink: opus codec error");
+ }
+ @Override public void onOutputFormatChanged(MediaCodec mc, MediaFormat f) {
+ Log.i("audio sink: opus output format %s", f);
+ }
+ }, opusHandler);
+ }
+ }
+
+ @Override
+ public void feed(byte[] data, int off, int len, boolean isConfig) {
+ if (released || len <= 0) return;
+ if (fourcc == Wire.CODEC_OPUS) {
+ feedOpus(data, off, len, isConfig);
+ } else {
+ // Raw PCM: passthrough.
+ frames++;
+ writePcm(data, off, len);
+ }
+ }
+
+ private void feedOpus(byte[] data, int off, int len, boolean isConfig) {
+ if (isConfig) {
+ if (opusConfigured) return; // already configured
+ // OpusHead is 19 bytes; pre_skip lives at bytes [10..11]. Reject
+ // anything shorter before indexing into it.
+ if (len < 19) {
+ Log.e("audio sink: opus head too short (%d bytes)", len);
+ return;
+ }
+ try {
+ byte[] head = new byte[len];
+ System.arraycopy(data, off, head, 0, len);
+ int preSkipSamples = ((head[10] & 0xff) | ((head[11] & 0xff) << 8));
+ long preSkipNs = preSkipSamples * 1_000_000_000L / SAMPLE_RATE;
+
+ MediaFormat fmt = MediaFormat.createAudioFormat(
+ MediaFormat.MIMETYPE_AUDIO_OPUS, SAMPLE_RATE, 2);
+ fmt.setByteBuffer("csd-0", ByteBuffer.wrap(head));
+ fmt.setByteBuffer("csd-1", longLeBytes(preSkipNs));
+ fmt.setByteBuffer("csd-2", longLeBytes(DEFAULT_PRE_ROLL_NS));
+ opusCodec.configure(fmt, null, null, 0);
+ opusCodec.start();
+ opusConfigured = true;
+ Log.i("audio sink: opus configured, pre_skip=%d ns", preSkipNs);
+ } catch (Exception e) {
+ Log.e(e, "audio sink: opus configure");
+ }
+ return;
+ }
+ if (!opusConfigured) return;
+ // Encoded packet → MediaCodec input.
+ int idx;
+ try { idx = opusCodec.dequeueInputBuffer(0); }
+ catch (IllegalStateException e) { return; }
+ if (idx < 0) {
+ // No input buffer right now; drop the packet. Opus is forgiving
+ // about gaps for short stalls.
+ return;
+ }
+ try {
+ ByteBuffer in = opusCodec.getInputBuffer(idx);
+ if (in == null) return;
+ in.clear();
+ in.put(data, off, len);
+ opusCodec.queueInputBuffer(idx, 0, len, 0, 0);
+ frames++;
+ } catch (IllegalStateException e) {
+ Log.w("audio sink: opus queueInputBuffer: %s", e);
+ }
+ }
+
+ // Encode a long little-endian for MediaFormat csd-1 / csd-2.
+ private static ByteBuffer longLeBytes(long v) {
+ ByteBuffer b = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(v);
+ b.flip();
+ return b;
+ }
+
+ private void writePcm(byte[] data, int off, int len) {
+ AudioTrack t = track;
+ if (released || t == null || len <= 0) return;
+ int written = t.write(data, off, len, AudioTrack.WRITE_NON_BLOCKING);
+ if (written < 0) {
+ Log.w("audio sink: write rc=%d", written);
+ return;
+ }
+ if (written < len) {
+ droppedBytes += (len - written);
+ long now = System.currentTimeMillis();
+ if (now - lastDropLogMs > 1000L) {
+ lastDropLogMs = now;
+ Log.w("audio sink: ring full, dropped %d bytes so far", droppedBytes);
+ }
+ }
+ }
+
+ @Override
+ public void release() {
+ released = true;
+ MediaCodec c = opusCodec;
+ opusCodec = null;
+ HandlerThread ht = opusThread;
+ opusThread = null;
+ opusHandler = null;
+ if (c != null) {
+ try { c.stop(); } catch (Exception ignored) {}
+ try { c.release(); } catch (Exception ignored) {}
+ }
+ if (ht != null) ht.quitSafely();
+
+ AudioTrack t = track;
+ track = null;
+ if (t == null) return;
+ try { t.pause(); t.flush(); t.stop(); } catch (Exception ignored) {}
+ try { t.release(); } catch (Exception ignored) {}
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/AudioStream.java b/app/src/main/java/invalid/lena/scrcpy/AudioStream.java
new file mode 100644
index 0000000..39b184d
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/AudioStream.java
@@ -0,0 +1,93 @@
+package invalid.lena.scrcpy;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+// Reads the scrcpy audio socket and drives an AudioFrames sink.
+//
+// Wire format (big-endian, scrcpy 3.x/4.x):
+// 1) Stream meta: uint32 fourcc
+// special values 0 = disabled, 1 = error
+// 2) Loop: 12-byte frame header (uint64 ptsAndFlags | uint32 size)
+// followed by `size` bytes of payload.
+//
+// We honour FLAG_CONFIG on the per-frame header: for opus the first
+// frame is the OpusHead (sent with FLAG_CONFIG set), subsequent are
+// opus packets. For raw PCM the server never sets FLAG_CONFIG.
+//
+// Takes a plain InputStream; caller owns stream lifecycle.
+public final class AudioStream {
+
+ private static final long FLAG_CONFIG = 1L << 62;
+
+ // Generous upper bound for one audio packet (a raw PCM block or an
+ // opus packet is a few KB). A corrupt or hostile length field must
+ // not drive the allocation below.
+ private static final int MAX_FRAME_SIZE = 1024 * 1024;
+
+ private final InputStream source;
+ private final AudioFrames sink;
+ private Thread thread;
+ private volatile boolean stop;
+
+ public AudioStream(InputStream source, AudioFrames sink) {
+ this.source = source;
+ this.sink = sink;
+ }
+
+ public void start() {
+ thread = new Thread(this::run, "audio-reader");
+ thread.start();
+ }
+
+ public void stop() {
+ stop = true;
+ if (thread != null) thread.interrupt();
+ }
+
+ public void run() {
+ try {
+ byte[] four = new byte[4];
+ Wire.readFully(source, four);
+ int fourcc = Wire.readBe32(four, 0);
+ if (fourcc == 0) {
+ Log.w("audio: server reports stream disabled (target cannot capture)");
+ return;
+ }
+ if (fourcc == 1) {
+ Log.e("audio: server reports configuration error");
+ return;
+ }
+ if (fourcc != Wire.CODEC_RAW && fourcc != Wire.CODEC_OPUS) {
+ Log.w("audio: unexpected codec %s - keeping silent",
+ Wire.fourccName(fourcc));
+ return;
+ }
+ Log.i("audio meta codec=%s", Wire.fourccName(fourcc));
+ sink.start(fourcc);
+
+ byte[] hdr = new byte[12];
+ byte[] payload = new byte[16 * 1024];
+ long frames = 0;
+ while (!stop) {
+ Wire.readFully(source, hdr);
+ long ptsAndFlags = Wire.readBe64(hdr, 0);
+ int size = Wire.readBe32(hdr, 8);
+ boolean cfg = (ptsAndFlags & FLAG_CONFIG) != 0;
+ if (size <= 0 || size > MAX_FRAME_SIZE) {
+ throw new IOException("audio frame size out of range: " + size);
+ }
+ if (size > payload.length) payload = new byte[size];
+ Wire.readFully(source, payload, 0, size);
+ sink.feed(payload, 0, size, cfg);
+ if (++frames == 1) Log.i("audio frame n=1 size=%d cfg=%s", size, cfg);
+ }
+ } catch (IOException e) {
+ if (!stop) Log.e(e, "audio reader");
+ } catch (Exception e) {
+ Log.e(e, "audio reader unexpected");
+ } finally {
+ Log.i("audio reader: end");
+ }
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/ControlMessages.java b/app/src/main/java/invalid/lena/scrcpy/ControlMessages.java
new file mode 100644
index 0000000..bd983ed
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/ControlMessages.java
@@ -0,0 +1,72 @@
+package invalid.lena.scrcpy;
+
+import java.nio.charset.StandardCharsets;
+
+// Pure-java encoders for scrcpy control messages. Layouts mirror
+// com.genymobile.scrcpy.control.ControlMessageReader in the scrcpy
+// server source. Extracted from Controller so the wire format can be
+// unit-tested without android.* on the classpath.
+public final class ControlMessages {
+
+ public static final int TYPE_INJECT_KEYCODE = 0;
+ public static final int TYPE_INJECT_TEXT = 1;
+ public static final int TYPE_INJECT_TOUCH_EVENT = 2;
+ public static final int TYPE_BACK_OR_SCREEN_ON = 4;
+ public static final int TYPE_SET_CLIPBOARD = 9;
+
+ // KeyEvent.ACTION_DOWN / ACTION_UP. Mirror the int values rather
+ // than depend on android.view.KeyEvent so this stays android-free.
+ public static final int ACTION_DOWN = 0;
+ public static final int ACTION_UP = 1;
+
+ public static final int TOUCH_MSG_LEN = 32; // 1 + 1 + 8 + 4 + 4 + 2 + 2 + 2 + 4 + 4
+ public static final int KEY_MSG_LEN = 14; // 1 + 1 + 4 + 4 + 4
+ public static final int BACK_MSG_LEN = 2; // 1 + 1
+
+ private ControlMessages() {}
+
+ public static byte[] touch(int action, long pointerId,
+ int x, int y, int targetW, int targetH,
+ int pressureU16, int actionButton, int buttons) {
+ byte[] m = new byte[TOUCH_MSG_LEN];
+ m[0] = TYPE_INJECT_TOUCH_EVENT;
+ m[1] = (byte) action;
+ Wire.writeBe64(m, 2, pointerId);
+ Wire.writeBe32(m, 10, x);
+ Wire.writeBe32(m, 14, y);
+ m[18] = (byte)(targetW >>> 8); m[19] = (byte) targetW;
+ m[20] = (byte)(targetH >>> 8); m[21] = (byte) targetH;
+ m[22] = (byte)(pressureU16 >>> 8); m[23] = (byte) pressureU16;
+ Wire.writeBe32(m, 24, actionButton);
+ Wire.writeBe32(m, 28, buttons);
+ return m;
+ }
+
+ public static byte[] keycode(int action, int keycode, int repeat, int metaState) {
+ byte[] m = new byte[KEY_MSG_LEN];
+ m[0] = TYPE_INJECT_KEYCODE;
+ m[1] = (byte) action;
+ Wire.writeBe32(m, 2, keycode);
+ Wire.writeBe32(m, 6, repeat);
+ Wire.writeBe32(m, 10, metaState);
+ return m;
+ }
+
+ // Single button-press event. scrcpy server interprets this as Back
+ // when the screen is on, or screen-on when it's off. We send a
+ // DOWN+UP pair to make a tap; this helper builds one byte pair.
+ public static byte[] backOrScreenOn(int action) {
+ return new byte[]{(byte) TYPE_BACK_OR_SCREEN_ON, (byte) action};
+ }
+
+ public static byte[] setClipboard(long sequence, boolean paste, String text) {
+ byte[] data = text.getBytes(StandardCharsets.UTF_8);
+ byte[] m = new byte[1 + 8 + 1 + 4 + data.length];
+ m[0] = TYPE_SET_CLIPBOARD;
+ Wire.writeBe64(m, 1, sequence);
+ m[9] = (byte)(paste ? 1 : 0);
+ Wire.writeBe32(m, 10, data.length);
+ System.arraycopy(data, 0, m, 14, data.length);
+ return m;
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/ControlStream.java b/app/src/main/java/invalid/lena/scrcpy/ControlStream.java
new file mode 100644
index 0000000..eea6c7d
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/ControlStream.java
@@ -0,0 +1,154 @@
+package invalid.lena.scrcpy;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.LinkedBlockingDeque;
+
+// Bidirectional bridge to the scrcpy control socket.
+//
+// Outbound: Controller hands us pre-encoded ControlMessage byte arrays
+// via send(); the writer thread drains them onto the OutputStream.
+// Queue is bounded; on overflow, the *oldest* intermediate
+// INJECT_TOUCH_EVENT with action=MOVE is dropped. Touch-down/up and key
+// events are never dropped, otherwise pointers get orphaned on the target.
+//
+// Inbound: the reader thread parses DeviceMessage frames from the
+// InputStream. v1 only cares about TYPE_CLIPBOARD; ACK_CLIPBOARD is
+// logged, UHID_OUTPUT is consumed and ignored.
+//
+// Constructor takes plain streams so this class is android-free.
+public final class ControlStream {
+
+ public interface InboundSink {
+ void onRemoteClipboard(String text);
+ }
+
+ private static final int TYPE_INJECT_TOUCH_EVENT = 2;
+ private static final int ACTION_MOVE = 2;
+
+ private static final int DEV_TYPE_CLIPBOARD = 0;
+ private static final int DEV_TYPE_ACK_CLIPBOARD = 1;
+ private static final int DEV_TYPE_UHID_OUTPUT = 2;
+
+ private static final int MAX_QUEUED = 256;
+
+ private final InputStream in;
+ private final OutputStream out;
+ private final LinkedBlockingDeque<byte[]> outbox = new LinkedBlockingDeque<>(MAX_QUEUED);
+
+ private volatile InboundSink sink;
+ private Thread writer;
+ private Thread reader;
+ private volatile boolean stop;
+
+ public ControlStream(InputStream in, OutputStream out) {
+ this.in = in;
+ this.out = out;
+ }
+
+ // Wired after construction: the Controller that consumes inbound
+ // messages needs this stream's send() to exist first.
+ public void setInboundSink(InboundSink sink) {
+ this.sink = sink;
+ }
+
+ public void start() {
+ writer = new Thread(this::runWriter, "control-writer");
+ reader = new Thread(this::runReader, "control-reader");
+ writer.start();
+ reader.start();
+ }
+
+ public void stop() {
+ stop = true;
+ outbox.clear();
+ if (writer != null) writer.interrupt();
+ if (reader != null) reader.interrupt();
+ }
+
+ public void send(byte[] msg) {
+ if (stop) return;
+ if (outbox.offerLast(msg)) return;
+
+ // Full: try to drop an intermediate touch-MOVE to make room.
+ // Touch-down/up and non-touch messages stay.
+ for (byte[] b : outbox) {
+ if (b.length >= 2 && b[0] == TYPE_INJECT_TOUCH_EVENT && (b[1] & 0xff) == ACTION_MOVE) {
+ if (outbox.remove(b)) break;
+ }
+ }
+ if (!outbox.offerLast(msg)) {
+ Log.w("control: outbox full, dropping msg type=%d", msg.length > 0 ? msg[0] & 0xff : -1);
+ }
+ }
+
+ // ---- writer ----
+
+ public void runWriter() {
+ try {
+ while (!stop) {
+ byte[] msg = outbox.takeFirst();
+ out.write(msg);
+ out.flush();
+ }
+ } catch (InterruptedException ignored) {
+ } catch (IOException e) {
+ if (!stop) Log.e(e, "control writer");
+ } finally {
+ Log.i("control writer: end");
+ }
+ }
+
+ // ---- reader ----
+
+ public void runReader() {
+ try {
+ byte[] tmp = new byte[12];
+ while (!stop) {
+ Wire.readFully(in, tmp, 0, 1);
+ int type = tmp[0] & 0xff;
+ switch (type) {
+ case DEV_TYPE_CLIPBOARD: {
+ Wire.readFully(in, tmp, 0, 4);
+ int len = Wire.readBe32(tmp, 0);
+ if (len < 0 || len > 1 << 20) {
+ throw new IOException("clipboard len out of range: " + len);
+ }
+ byte[] data = new byte[len];
+ Wire.readFully(in, data);
+ String text = new String(data, StandardCharsets.UTF_8);
+ InboundSink s = sink;
+ if (s != null) s.onRemoteClipboard(text);
+ break;
+ }
+ case DEV_TYPE_ACK_CLIPBOARD: {
+ Wire.readFully(in, tmp, 0, 8);
+ long seq = (long) Wire.readBe32(tmp, 0) << 32
+ | (Wire.readBe32(tmp, 4) & 0xffffffffL);
+ Log.i("control: ack clipboard seq=%d", seq);
+ break;
+ }
+ case DEV_TYPE_UHID_OUTPUT: {
+ Wire.readFully(in, tmp, 0, 4);
+ int len = ((tmp[2] & 0xff) << 8) | (tmp[3] & 0xff);
+ if (len > 0) {
+ byte[] skip = new byte[len];
+ Wire.readFully(in, skip);
+ }
+ break;
+ }
+ default:
+ throw new IOException("unknown DeviceMessage type=" + type);
+ }
+ }
+ } catch (IOException e) {
+ if (!stop) Log.e(e, "control reader");
+ } catch (Exception e) {
+ Log.e(e, "control reader unexpected");
+ } finally {
+ Log.i("control reader: end");
+ }
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Controller.java b/app/src/main/java/invalid/lena/scrcpy/Controller.java
new file mode 100644
index 0000000..88b9442
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Controller.java
@@ -0,0 +1,173 @@
+package invalid.lena.scrcpy;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+
+import java.util.function.Consumer;
+
+// Encodes UI events into scrcpy ControlMessage byte arrays and pushes
+// them at ControlStream. Also mirrors the remote clipboard locally and
+// echoes local clipboard changes the other way.
+//
+// Forwards multi-pointer touch and AOSP keycodes. Mouse buttons and
+// scroll wheels are not forwarded yet.
+public final class Controller implements ControlStream.InboundSink {
+
+ private final Consumer<byte[]> sender;
+ private final ClipboardManager clipboard;
+ public volatile String lastEvent = "(idle)";
+ // Held once so add/removePrimaryClipChangedListener see the same
+ // listener reference. Method references generate fresh lambdas
+ // each call site and the remove silently no-ops otherwise.
+ private final ClipboardManager.OnPrimaryClipChangedListener clipListener =
+ this::onLocalClipboardChanged;
+ private volatile int targetW, targetH;
+ private volatile int viewW, viewH;
+
+ // Suppress one local clipboard change after we set it from a remote update.
+ private volatile boolean suppressNextClipChange;
+
+ public Controller(Context ctx, Consumer<byte[]> sender) {
+ this.sender = sender;
+ this.clipboard = (ClipboardManager) ctx.getSystemService(Context.CLIPBOARD_SERVICE);
+ if (clipboard != null) {
+ clipboard.addPrimaryClipChangedListener(clipListener);
+ }
+ }
+
+ public void release() {
+ if (clipboard != null) {
+ try { clipboard.removePrimaryClipChangedListener(clipListener); }
+ catch (Exception ignored) {}
+ }
+ }
+
+ public void setTargetSize(int w, int h) {
+ targetW = w; targetH = h;
+ Log.i("controller: target %dx%d", w, h);
+ }
+
+ public void setViewSize(int w, int h) {
+ viewW = w; viewH = h;
+ }
+
+ // ---- inbound ----
+
+ @Override
+ public void onRemoteClipboard(String text) {
+ Log.i("clipboard from target: %d chars", text.length());
+ if (clipboard == null) return;
+ suppressNextClipChange = true;
+ try {
+ clipboard.setPrimaryClip(ClipData.newPlainText("scrcpy-android", text));
+ } catch (Exception e) {
+ Log.w("clipboard set local failed: %s", e);
+ suppressNextClipChange = false;
+ }
+ }
+
+ private void onLocalClipboardChanged() {
+ if (suppressNextClipChange) {
+ suppressNextClipChange = false;
+ return;
+ }
+ if (clipboard == null) return;
+ ClipData data;
+ try { data = clipboard.getPrimaryClip(); }
+ catch (Exception e) { Log.w("clipboard get local failed: %s", e); return; }
+ if (data == null || data.getItemCount() == 0) return;
+ CharSequence cs = data.getItemAt(0).coerceToText(null);
+ if (cs == null) return;
+ sendSetClipboard(cs.toString(), false);
+ Log.i("clipboard to target: %d chars", cs.length());
+ }
+
+ // ---- outbound ----
+
+ public void onTouch(MotionEvent ev) {
+ int tw = targetW, th = targetH, vw = viewW, vh = viewH;
+ if (tw == 0 || th == 0 || vw == 0 || vh == 0) return;
+
+ // scrcpy's wire protocol uses ACTION_DOWN/UP/MOVE/CANCEL with a
+ // pointerId per message. The server tracks which pointers are
+ // currently down. So we translate Android's masked actions:
+ // ACTION_POINTER_DOWN[i] -> ACTION_DOWN (this pointer joins)
+ // ACTION_POINTER_UP[i] -> ACTION_UP (this pointer leaves)
+ // ACTION_MOVE -> ACTION_MOVE for every current pointer
+ // ACTION_CANCEL -> ACTION_CANCEL for every current pointer
+ int action = ev.getActionMasked();
+ int idx = ev.getActionIndex();
+ int n = ev.getPointerCount();
+
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ sendPointer(ev, 0, MotionEvent.ACTION_DOWN, tw, th, vw, vh);
+ break;
+ case MotionEvent.ACTION_POINTER_DOWN:
+ sendPointer(ev, idx, MotionEvent.ACTION_DOWN, tw, th, vw, vh);
+ break;
+ case MotionEvent.ACTION_UP:
+ sendPointer(ev, 0, MotionEvent.ACTION_UP, tw, th, vw, vh);
+ break;
+ case MotionEvent.ACTION_POINTER_UP:
+ sendPointer(ev, idx, MotionEvent.ACTION_UP, tw, th, vw, vh);
+ break;
+ case MotionEvent.ACTION_MOVE:
+ for (int i = 0; i < n; i++) sendPointer(ev, i, MotionEvent.ACTION_MOVE, tw, th, vw, vh);
+ break;
+ case MotionEvent.ACTION_CANCEL:
+ for (int i = 0; i < n; i++) sendPointer(ev, i, MotionEvent.ACTION_CANCEL, tw, th, vw, vh);
+ break;
+ default:
+ return;
+ }
+ }
+
+ // Sizes come from onTouch's snapshot of the volatile fields, so a
+ // concurrent resize cannot zero a divisor between check and use.
+ private void sendPointer(MotionEvent ev, int index, int action,
+ int tw, int th, int vw, int vh) {
+ long pointerId = ev.getPointerId(index);
+ int x = (int) ev.getX(index);
+ int y = (int) ev.getY(index);
+ int tx = (int) ((long) x * tw / vw);
+ int ty = (int) ((long) y * th / vh);
+ int pressure = (action == MotionEvent.ACTION_UP) ? 0
+ : (int)(ev.getPressure(index) * 0xffff) & 0xffff;
+ sender.accept(ControlMessages.touch(action, pointerId, tx, ty, tw, th,
+ pressure, /* actionButton */ 0, /* buttons */ 0));
+ lastEvent = "touch a=" + action + " (" + tx + "," + ty + ")";
+ }
+
+ public void onKey(KeyEvent ev) {
+ int tw = targetW, th = targetH;
+ if (tw == 0 || th == 0) return;
+ int action = ev.getAction(); // ACTION_DOWN=0, ACTION_UP=1
+ if (action != KeyEvent.ACTION_DOWN && action != KeyEvent.ACTION_UP) return;
+ sendKeycode(action, ev.getKeyCode(), ev.getRepeatCount(), ev.getMetaState());
+ }
+
+ // Fire a tap of the target's Back. scrcpy server treats the
+ // BACK_OR_SCREEN_ON message as Back when the target is on, screen-
+ // on when it's off - useful for waking a locked target too.
+ public void onBack() {
+ sender.accept(ControlMessages.backOrScreenOn(ControlMessages.ACTION_DOWN));
+ sender.accept(ControlMessages.backOrScreenOn(ControlMessages.ACTION_UP));
+ lastEvent = "back";
+ }
+
+ // ---- encoders (delegate to pure-java ControlMessages) ----
+
+ private void sendKeycode(int action, int keycode, int repeat, int metaState) {
+ sender.accept(ControlMessages.keycode(action, keycode, repeat, metaState));
+ lastEvent = "key a=" + action + " kc=" + keycode;
+ }
+
+ private void sendSetClipboard(String text, boolean paste) {
+ sender.accept(ControlMessages.setClipboard(/* sequence */ 0L, paste, text));
+ lastEvent = "clip " + text.length() + " chars";
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Crashlog.java b/app/src/main/java/invalid/lena/scrcpy/Crashlog.java
new file mode 100644
index 0000000..00bd3d8
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Crashlog.java
@@ -0,0 +1,48 @@
+package invalid.lena.scrcpy;
+
+import android.content.Context;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.PrintWriter;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+// Catches uncaught exceptions on any thread, writes them as plain
+// text to <externalFilesDir>/crash-<timestamp>.log, then chains to
+// the system default handler so the OS crash dialog still appears.
+// Testers can share the file from the app's storage instead of
+// fishing for it in adb logcat.
+public final class Crashlog {
+
+ private Crashlog() {}
+
+ public static void install(Context ctx) {
+ File dir = ctx.getExternalFilesDir(null);
+ if (dir == null) {
+ Log.w("crashlog: no external storage, skipping install");
+ return;
+ }
+ Thread.UncaughtExceptionHandler prev =
+ Thread.getDefaultUncaughtExceptionHandler();
+ Thread.setDefaultUncaughtExceptionHandler((t, e) -> {
+ try {
+ String ts = new SimpleDateFormat("yyyyMMdd-HHmmss",
+ Locale.ROOT).format(new Date());
+ File out = new File(dir, "crash-" + ts + ".log");
+ try (PrintWriter pw = new PrintWriter(new FileWriter(out))) {
+ pw.println("# " + new Date());
+ pw.println("# thread=" + t.getName());
+ pw.println();
+ e.printStackTrace(pw);
+ }
+ Log.e(e, "crashlog: wrote %s", out.getAbsolutePath());
+ } catch (Throwable ignored) {
+ // best effort - do not mask the original crash
+ }
+ if (prev != null) prev.uncaughtException(t, e);
+ });
+ Log.i("crashlog: installed -> %s", dir.getAbsolutePath());
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Devices.java b/app/src/main/java/invalid/lena/scrcpy/Devices.java
new file mode 100644
index 0000000..2883025
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Devices.java
@@ -0,0 +1,126 @@
+package invalid.lena.scrcpy;
+
+import android.content.Context;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+
+// Tiny JSON-backed list of paired targets, stored at filesDir/devices.json.
+// No SQLite, no Room. Read on demand, written whole-file on add/remove.
+//
+// parse() / serialize() are pure-java and android-free for unit tests.
+// load() / save() add the Context-rooted file I/O.
+public final class Devices {
+
+ private static final String FILE = "devices.json";
+
+ public static final class Device {
+ public final String host;
+ public final int port;
+
+ public Device(String host, int port) {
+ this.host = host;
+ this.port = port;
+ }
+
+ @Override
+ public String toString() {
+ return host + ":" + port;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof Device d)) return false;
+ return port == d.port && host.equals(d.host);
+ }
+
+ @Override
+ public int hashCode() {
+ return host.hashCode() * 31 + port;
+ }
+ }
+
+ private Devices() {}
+
+ // Pure-java parse: returns whatever rows are well-formed; logs and
+ // skips anything malformed instead of nuking the list.
+ public static List<Device> parse(String json) {
+ List<Device> out = new ArrayList<>();
+ if (json == null) return out;
+ json = json.trim();
+ if (json.isEmpty()) return out;
+ JSONArray arr;
+ try { arr = new JSONArray(json); }
+ catch (Exception e) { Log.e(e, "devices: not a json array"); return out; }
+
+ for (int i = 0; i < arr.length(); i++) {
+ try {
+ JSONObject o = arr.getJSONObject(i);
+ out.add(new Device(o.getString("host"), o.getInt("port")));
+ } catch (Exception e) {
+ Log.w("devices: skipping malformed row %d: %s", i, e);
+ }
+ }
+ return out;
+ }
+
+ public static String serialize(List<Device> devices) {
+ try {
+ JSONArray arr = new JSONArray();
+ for (Device d : devices) {
+ JSONObject o = new JSONObject();
+ o.put("host", d.host);
+ o.put("port", d.port);
+ arr.put(o);
+ }
+ return arr.toString(2);
+ } catch (Exception e) {
+ Log.e(e, "devices: serialize failed");
+ return "[]";
+ }
+ }
+
+ public static List<Device> load(Context ctx) {
+ File f = new File(ctx.getFilesDir(), FILE);
+ if (!f.exists()) return new ArrayList<>();
+ try {
+ return parse(new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8));
+ } catch (Exception e) {
+ Log.e(e, "devices: load failed");
+ return new ArrayList<>();
+ }
+ }
+
+ public static void save(Context ctx, List<Device> devices) {
+ File f = new File(ctx.getFilesDir(), FILE);
+ try {
+ AtomicFiles.write(f, serialize(devices).getBytes(StandardCharsets.UTF_8));
+ } catch (Exception e) {
+ Log.e(e, "devices: save failed");
+ }
+ }
+
+ // Add or replace by host+port. Context-rooted; the in-place helper
+ // is the test seam.
+ public static List<Device> upsert(Context ctx, Device d) {
+ List<Device> list = load(ctx);
+ list.removeIf(d::equals);
+ list.add(d);
+ save(ctx, list);
+ return list;
+ }
+
+ // Remove the matching device (by host+port). Returns the updated list.
+ public static List<Device> remove(Context ctx, Device d) {
+ List<Device> list = load(ctx);
+ list.removeIf(d::equals);
+ save(ctx, list);
+ return list;
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Log.java b/app/src/main/java/invalid/lena/scrcpy/Log.java
new file mode 100644
index 0000000..f2c7e87
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Log.java
@@ -0,0 +1,26 @@
+package invalid.lena.scrcpy;
+
+import java.util.Locale;
+
+// Single static wrapper around android.util.Log so every line in this app
+// uses the same tag. Filter the whole app with `adb logcat -s scrcpy-android`.
+//
+// String.format always uses Locale.ROOT so logs are unaffected by device
+// locales that use comma decimals or other surprises.
+public final class Log {
+ public static final String TAG = "scrcpy-android";
+
+ private Log() {}
+
+ public static void i(String fmt, Object... a) { android.util.Log.i(TAG, fmt(fmt, a)); }
+ public static void w(String fmt, Object... a) { android.util.Log.w(TAG, fmt(fmt, a)); }
+ public static void e(String fmt, Object... a) { android.util.Log.e(TAG, fmt(fmt, a)); }
+
+ public static void e(Throwable t, String fmt, Object... a) {
+ android.util.Log.e(TAG, fmt(fmt, a), t);
+ }
+
+ private static String fmt(String fmt, Object... a) {
+ return a.length == 0 ? fmt : String.format(Locale.ROOT, fmt, a);
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Main.java b/app/src/main/java/invalid/lena/scrcpy/Main.java
new file mode 100644
index 0000000..104f57f
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Main.java
@@ -0,0 +1,150 @@
+package invalid.lena.scrcpy;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.util.List;
+
+// Pairing form + saved-device list.
+//
+// Layout: host, pair-port, pair-code, connect-port + a single "Pair and save"
+// button. After a successful pair() against the daemon, the row is appended
+// to devices.json with the *connect* port (different from the pair port on
+// Android 11+). Tapping a saved row launches the Mirror activity with the
+// target host/port; Mirror owns its own Adb instance loaded from the same
+// on-disk keypair.
+public final class Main extends Activity {
+
+ private volatile Adb adb;
+ private ArrayAdapter<Devices.Device> adapter;
+ private Button pairButton;
+
+ @Override
+ protected void onCreate(Bundle saved) {
+ super.onCreate(saved);
+ setContentView(R.layout.main);
+
+ EditText host = findViewById(R.id.host);
+ EditText pairPort = findViewById(R.id.pair_port);
+ EditText pairCode = findViewById(R.id.pair_code);
+ EditText connectPort = findViewById(R.id.connect_port);
+ pairButton = findViewById(R.id.pair);
+ View settingsBtn = findViewById(R.id.settings);
+ ListView devices = findViewById(R.id.devices);
+ TextView devicesEmpty = findViewById(R.id.devices_empty);
+
+ settingsBtn.setOnClickListener(v -> startActivity(
+ new Intent(this, SettingsActivity.class)));
+
+ adapter = new ArrayAdapter<>(this, R.layout.device_row, R.id.device_label, Devices.load(this));
+ devices.setAdapter(adapter);
+ devices.setEmptyView(devicesEmpty);
+
+ // RSA keygen on first launch can take 1-3 s; never on the UI thread.
+ pairButton.setEnabled(false);
+ new Thread(() -> {
+ try {
+ Adb a = Adb.getInstance(this);
+ runOnUiThread(() -> {
+ adb = a;
+ pairButton.setEnabled(true);
+ });
+ } catch (Exception e) {
+ Log.e(e, "adb init failed");
+ runOnUiThread(() -> {
+ Toast.makeText(this, "adb init failed: " + e.getMessage(),
+ Toast.LENGTH_LONG).show();
+ finish();
+ });
+ }
+ }, "adb-init").start();
+
+ devices.setOnItemClickListener((parent, view, pos, id) -> {
+ Devices.Device d = adapter.getItem(pos);
+ Log.i("connect tap: %s", d);
+ Intent i = new Intent(this, Mirror.class);
+ i.putExtra(Mirror.EXTRA_HOST, d.host);
+ i.putExtra(Mirror.EXTRA_PORT, d.port);
+ startActivity(i);
+ });
+
+ devices.setOnItemLongClickListener((parent, view, pos, id) -> {
+ Devices.Device d = adapter.getItem(pos);
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.forget_device)
+ .setMessage(d.host + ":" + d.port)
+ .setPositiveButton(android.R.string.ok, (dlg, w) -> {
+ Log.i("forget device: %s", d);
+ List<Devices.Device> updated = Devices.remove(this, d);
+ adapter.clear();
+ adapter.addAll(updated);
+ adapter.notifyDataSetChanged();
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ return true;
+ });
+
+ pairButton.setOnClickListener(v -> {
+ if (adb == null) return; // still initialising
+ String h = host.getText().toString().trim();
+ String pp = pairPort.getText().toString().trim();
+ String pc = pairCode.getText().toString().trim();
+ String cp = connectPort.getText().toString().trim();
+ if (TextUtils.isEmpty(h) || TextUtils.isEmpty(pp)
+ || TextUtils.isEmpty(pc) || TextUtils.isEmpty(cp)) {
+ Toast.makeText(this, "fill all four fields", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ int pairP, connP;
+ try {
+ pairP = Integer.parseInt(pp);
+ connP = Integer.parseInt(cp);
+ } catch (NumberFormatException e) {
+ Toast.makeText(this, "ports must be numeric", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (pairP < 1 || pairP > 65535 || connP < 1 || connP > 65535) {
+ Toast.makeText(this, "ports must be 1-65535", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ pairButton.setEnabled(false);
+ Toast.makeText(this, R.string.pairing, Toast.LENGTH_SHORT).show();
+ new Thread(() -> pairAndSave(h, pairP, pc, connP, pairButton), "pair").start();
+ });
+ }
+
+ private void pairAndSave(String host, int pairPort, String code, int connPort, Button btn) {
+ try {
+ Log.i("pair: %s:%d", host, pairPort);
+ boolean ok = adb.pair(host, pairPort, code);
+ if (!ok) throw new IllegalStateException("pair returned false");
+ Log.i("pair ok host=%s pair_port=%d", host, pairPort);
+ Devices.Device d = new Devices.Device(host, connPort);
+ List<Devices.Device> updated = Devices.upsert(this, d);
+ runOnUiThread(() -> {
+ adapter.clear();
+ adapter.addAll(updated);
+ adapter.notifyDataSetChanged();
+ Toast.makeText(this, "paired and saved", Toast.LENGTH_SHORT).show();
+ btn.setEnabled(true);
+ });
+ } catch (Exception e) {
+ Log.e(e, "pair failed");
+ runOnUiThread(() -> {
+ Toast.makeText(this, "pair failed: " + e.getMessage(), Toast.LENGTH_LONG).show();
+ btn.setEnabled(true);
+ });
+ }
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Mirror.java b/app/src/main/java/invalid/lena/scrcpy/Mirror.java
new file mode 100644
index 0000000..f6e249d
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Mirror.java
@@ -0,0 +1,471 @@
+package invalid.lena.scrcpy;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.SurfaceTexture;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.TextureView;
+import android.view.View;
+import android.view.WindowInsets;
+import android.view.WindowInsetsController;
+import android.view.WindowManager;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.io.File;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+// Full-screen mirror activity. Pulls target host/port from intent
+// extras, starts a Sessions foreground service to keep the process
+// alive during brief backgrounding, and owns the Session itself.
+//
+// Two layouts ship: src/main/res/layout/mirror.xml (release: SurfaceView
+// + status bar) and src/debug/res/layout/mirror.xml (debug: TextureView
+// + status bar + bottom stats overlay used by the e2e screen capture
+// because emulators don't composite SurfaceView into screencap).
+//
+// Surface lifetime is decoupled from session lifetime: when the surface
+// goes away (rotation, background) we swap the session's video surface
+// to null and let the wire keep draining. When the surface comes back
+// we swap the new one in. Audio and control streams are unaffected.
+//
+// Session state vs activity lifetime: a fatal session error does NOT
+// finish() the activity any more - instead the status bar transitions
+// to DISCONNECTED and exposes a Reconnect button.
+public final class Mirror extends Activity {
+
+ public static final String EXTRA_HOST = "host";
+ public static final String EXTRA_PORT = "port";
+
+ // Arbitrary request code for POST_NOTIFICATIONS - we don't react to
+ // the result; the system caches the choice for next launch.
+ private static final int RQ_POST_NOTIFICATIONS = 1001;
+
+ private enum State { CONNECTING, CONNECTED, DISCONNECTED }
+
+ private volatile Adb adb;
+ private Devices.Device target;
+ private Session session;
+ private Surface currentSurface;
+ private State state = State.CONNECTING;
+ private int connectedW, connectedH;
+
+ // Only one of these is non-null per build variant.
+ private TextureView textureView;
+ private SurfaceView surfaceView;
+
+ // Always present (declared in both layouts).
+ private View statusBar;
+ private TextView statusText;
+ private Button reconnectBtn;
+ private Button recordBtn;
+
+ // Overlay TextViews - only present in the debug layout. null in release.
+ private TextView overlayTarget, overlayStats, overlayEvent;
+ private final Handler ui = new Handler(Looper.getMainLooper());
+ private int textureUpdates;
+
+ @Override
+ protected void onCreate(Bundle saved) {
+ super.onCreate(saved);
+ setContentView(R.layout.mirror);
+ immersive();
+ // Hold the source screen awake for as long as Mirror is in
+ // front. Cleared automatically when the activity is destroyed.
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+
+ String host = getIntent().getStringExtra(EXTRA_HOST);
+ int port = getIntent().getIntExtra(EXTRA_PORT, -1);
+ if (host == null || port <= 0) {
+ Log.e("mirror: bad extras host=%s port=%d", host, port);
+ finish();
+ return;
+ }
+ target = new Devices.Device(host, port);
+
+ View v = findViewById(R.id.surface);
+ if (v instanceof TextureView) {
+ textureView = (TextureView) v;
+ textureView.setSurfaceTextureListener(textureListener);
+ } else if (v instanceof SurfaceView) {
+ surfaceView = (SurfaceView) v;
+ surfaceView.getHolder().addCallback(holderCallback);
+ } else {
+ Log.e("mirror: layout has no SurfaceView or TextureView at R.id.surface");
+ finish();
+ return;
+ }
+
+ statusBar = findViewById(R.id.status_bar);
+ statusText = findViewById(R.id.status_text);
+ reconnectBtn = findViewById(R.id.reconnect);
+ recordBtn = findViewById(R.id.record);
+ reconnectBtn.setOnClickListener(view -> reconnect());
+ recordBtn.setOnClickListener(view -> toggleRecord());
+ updateStatusBar();
+
+ overlayTarget = findViewById(R.id.overlay_target);
+ overlayStats = findViewById(R.id.overlay_stats);
+ overlayEvent = findViewById(R.id.overlay_event);
+ if (overlayTarget != null) {
+ overlayTarget.setText("target: " + host + ":" + port);
+ ui.postDelayed(this::pollOverlay, 200);
+ }
+
+ requestNotificationsIfNeeded();
+ startForegroundService(new Intent(this, Sessions.class));
+
+ if (!Settings.hintBackShown(this)) {
+ Toast.makeText(this, R.string.hint_long_press_back,
+ Toast.LENGTH_LONG).show();
+ Settings.setHintBackShown(this, true);
+ }
+
+ new Thread(() -> {
+ try {
+ Adb a = Adb.getInstance(this);
+ runOnUiThread(() -> {
+ adb = a;
+ if (session == null && currentSurface != null) {
+ startSession(currentSurface);
+ }
+ });
+ } catch (Exception e) {
+ Log.e(e, "mirror: adb init");
+ runOnUiThread(() -> {
+ Toast.makeText(this, "adb init: " + e.getMessage(),
+ Toast.LENGTH_LONG).show();
+ finish();
+ });
+ }
+ }, "adb-init").start();
+ }
+
+ // ---- surface lifecycle: TextureView path ----
+
+ private final TextureView.SurfaceTextureListener textureListener =
+ new TextureView.SurfaceTextureListener() {
+ @Override
+ public void onSurfaceTextureAvailable(SurfaceTexture st, int w, int h) {
+ Log.i("mirror: texture available %dx%d", w, h);
+ attachSurface(new Surface(st), w, h);
+ }
+ @Override
+ public void onSurfaceTextureSizeChanged(SurfaceTexture st, int w, int h) {
+ Log.i("mirror: texture resized %dx%d", w, h);
+ if (session != null) session.setViewSize(w, h);
+ }
+ @Override
+ public boolean onSurfaceTextureDestroyed(SurfaceTexture st) {
+ Log.i("mirror: texture destroyed");
+ detachSurface();
+ return true;
+ }
+ @Override
+ public void onSurfaceTextureUpdated(SurfaceTexture st) {
+ if (++textureUpdates == 1 || textureUpdates % 30 == 0) {
+ Log.i("mirror: texture updates=%d", textureUpdates);
+ }
+ }
+ };
+
+ // ---- surface lifecycle: SurfaceView path ----
+
+ private final SurfaceHolder.Callback holderCallback = new SurfaceHolder.Callback() {
+ @Override
+ public void surfaceCreated(SurfaceHolder holder) {
+ Log.i("mirror: surface created");
+ attachSurface(holder.getSurface(), 0, 0);
+ }
+ @Override
+ public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {
+ Log.i("mirror: surface changed %dx%d", w, h);
+ if (session != null) session.setViewSize(w, h);
+ }
+ @Override
+ public void surfaceDestroyed(SurfaceHolder holder) {
+ Log.i("mirror: surface destroyed");
+ detachSurface();
+ }
+ };
+
+ // ---- session driver ----
+
+ private void attachSurface(Surface s, int w, int h) {
+ currentSurface = s;
+ if (session != null) {
+ session.swapSurface(s);
+ if (w > 0) session.setViewSize(w, h);
+ return;
+ }
+ if (adb == null) return; // adb-init thread will start the session
+ if (state == State.DISCONNECTED) return; // wait for user to tap reconnect
+ startSession(s);
+ if (w > 0) session.setViewSize(w, h);
+ }
+
+ private void detachSurface() {
+ currentSurface = null;
+ if (session != null) session.swapSurface(null);
+ }
+
+ private void startSession(Surface s) {
+ state = State.CONNECTING;
+ updateStatusBar();
+ session = new Session(this, adb, target, s, new Session.Listener() {
+ @Override public void onConnected(int w, int h) {
+ runOnUiThread(() -> {
+ state = State.CONNECTED;
+ connectedW = w; connectedH = h;
+ updateStatusBar();
+ });
+ }
+ @Override public void onReconnecting() {
+ Log.i("mirror: link lost, reconnecting");
+ runOnUiThread(() -> {
+ state = State.CONNECTING;
+ updateStatusBar();
+ });
+ }
+ @Override public void onError(Throwable t) {
+ runOnUiThread(() -> {
+ Toast.makeText(Mirror.this,
+ "session error: " + t.getMessage(), Toast.LENGTH_LONG).show();
+ });
+ }
+ @Override public void onStopped() {
+ Log.i("mirror: session stopped");
+ runOnUiThread(() -> {
+ state = State.DISCONNECTED;
+ updateStatusBar();
+ });
+ }
+ });
+ session.start();
+ }
+
+ private void toggleRecord() {
+ if (session == null) return;
+ if (session.isRecording()) {
+ session.stopRecording();
+ Toast.makeText(this, "recording saved", Toast.LENGTH_SHORT).show();
+ } else {
+ File dir = getExternalFilesDir(null);
+ if (dir == null) {
+ Toast.makeText(this, "no external storage", Toast.LENGTH_LONG).show();
+ return;
+ }
+ String ts = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).format(new Date());
+ File out = new File(dir, "scrcpy-" + ts + ".mp4");
+ session.armRecording(out);
+ Toast.makeText(this, "recording -> " + out.getName(), Toast.LENGTH_SHORT).show();
+ }
+ updateRecordButton();
+ }
+
+ private void updateRecordButton() {
+ boolean on = session != null && session.isRecording();
+ recordBtn.setText(on ? R.string.record_on : R.string.record);
+ }
+
+ private void reconnect() {
+ Log.i("mirror: reconnect tapped");
+ Session old = session;
+ session = null;
+ state = State.CONNECTING;
+ updateStatusBar();
+ // Stop the old session off the UI thread (teardown closes
+ // sockets and joins the server's log pump), THEN start the new
+ // one. Sequencing matters: both sessions share the singleton
+ // Adb, so the old teardown's disconnect must finish before the
+ // new bring-up connects.
+ new Thread(() -> {
+ if (old != null) old.stop();
+ runOnUiThread(() -> {
+ if (session != null) return; // another path already started one
+ if (adb == null || currentSurface == null) return;
+ startSession(currentSurface);
+ });
+ }, "session-stop").start();
+ }
+
+ private void updateStatusBar() {
+ if (statusText == null) return;
+ String s;
+ switch (state) {
+ case CONNECTED:
+ s = String.format(Locale.ROOT, "%s:%d %dx%d %s",
+ target.host, target.port, connectedW, connectedH,
+ Settings.videoCodec(this));
+ break;
+ case DISCONNECTED:
+ s = String.format(Locale.ROOT, "%s:%d %s",
+ target.host, target.port, getString(R.string.disconnected));
+ break;
+ default:
+ s = String.format(Locale.ROOT, "%s:%d %s",
+ target.host, target.port, getString(R.string.connecting));
+ }
+ statusText.setText(s);
+ reconnectBtn.setVisibility(state == State.DISCONNECTED ? View.VISIBLE : View.GONE);
+ recordBtn.setEnabled(state == State.CONNECTED);
+ updateRecordButton();
+
+ // Hide the whole bar while actively mirroring if the user opted
+ // out; keep it up whenever not CONNECTED so the status and the
+ // Reconnect button stay reachable.
+ if (statusBar != null) {
+ boolean show = Settings.showStatusBar(this) || state != State.CONNECTED;
+ statusBar.setVisibility(show ? View.VISIBLE : View.GONE);
+ }
+ }
+
+ // ---- overlay (debug only - fields are null in release) ----
+
+ private void pollOverlay() {
+ if (overlayStats == null) return;
+ if (session != null) {
+ overlayStats.setText(String.format(Locale.ROOT,
+ "v=%-4d a=%-4d tex=%-4d",
+ session.videoFrames(), session.audioFrames(), textureUpdates));
+ overlayEvent.setText("event: " + session.lastEvent());
+ }
+ ui.postDelayed(this::pollOverlay, 200);
+ }
+
+ // ---- input ----
+
+ @Override
+ public boolean onTouchEvent(MotionEvent ev) {
+ if (session != null) {
+ session.onTouch(ev);
+ return true;
+ }
+ return super.onTouchEvent(ev);
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ event.startTracking();
+ return true;
+ }
+ return super.onKeyDown(keyCode, event);
+ }
+
+ @Override
+ public boolean onKeyLongPress(int keyCode, KeyEvent event) {
+ if (keyCode == KeyEvent.KEYCODE_BACK && session != null) {
+ session.onBack();
+ return true;
+ }
+ return super.onKeyLongPress(keyCode, event);
+ }
+
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent ev) {
+ if (ev.getKeyCode() == KeyEvent.KEYCODE_BACK) {
+ return super.dispatchKeyEvent(ev);
+ }
+ if (session != null && shouldForward(ev)) {
+ session.onKey(ev);
+ return true;
+ }
+ return super.dispatchKeyEvent(ev);
+ }
+
+ // ---- lifecycle ----
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ ui.removeCallbacksAndMessages(null);
+ Session s = session;
+ session = null;
+ if (s != null) {
+ // Teardown blocks on socket closes and a thread join; keep
+ // it off the UI thread.
+ new Thread(s::stop, "session-stop").start();
+ }
+ stopService(new Intent(this, Sessions.class));
+ }
+
+ // Android 13+ requires runtime grant for POST_NOTIFICATIONS. The
+ // foreground service notification is silently suppressed if the
+ // user never sees the dialog, and on Android 14+ a notification-
+ // less FGS can be killed at any time.
+ private void requestNotificationsIfNeeded() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return;
+ if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED) return;
+ requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS},
+ RQ_POST_NOTIFICATIONS);
+ }
+
+ private void immersive() {
+ WindowInsetsController c = getWindow().getInsetsController();
+ if (c != null) {
+ c.hide(WindowInsets.Type.systemBars());
+ c.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
+ }
+ getWindow().setDecorFitsSystemWindows(false);
+ }
+
+ private static boolean shouldForward(KeyEvent ev) {
+ int code = ev.getKeyCode();
+ if (code >= KeyEvent.KEYCODE_DPAD_UP && code <= KeyEvent.KEYCODE_DPAD_CENTER) return true;
+ if (code >= KeyEvent.KEYCODE_0 && code <= KeyEvent.KEYCODE_9) return true;
+ if (code >= KeyEvent.KEYCODE_A && code <= KeyEvent.KEYCODE_Z) return true;
+ switch (code) {
+ case KeyEvent.KEYCODE_SPACE:
+ case KeyEvent.KEYCODE_ENTER:
+ case KeyEvent.KEYCODE_DEL:
+ case KeyEvent.KEYCODE_FORWARD_DEL:
+ case KeyEvent.KEYCODE_TAB:
+ case KeyEvent.KEYCODE_ESCAPE:
+ case KeyEvent.KEYCODE_PAGE_UP:
+ case KeyEvent.KEYCODE_PAGE_DOWN:
+ case KeyEvent.KEYCODE_MOVE_HOME:
+ case KeyEvent.KEYCODE_MOVE_END:
+ case KeyEvent.KEYCODE_INSERT:
+ case KeyEvent.KEYCODE_SHIFT_LEFT:
+ case KeyEvent.KEYCODE_SHIFT_RIGHT:
+ case KeyEvent.KEYCODE_CTRL_LEFT:
+ case KeyEvent.KEYCODE_CTRL_RIGHT:
+ case KeyEvent.KEYCODE_ALT_LEFT:
+ case KeyEvent.KEYCODE_ALT_RIGHT:
+ case KeyEvent.KEYCODE_META_LEFT:
+ case KeyEvent.KEYCODE_META_RIGHT:
+ case KeyEvent.KEYCODE_CAPS_LOCK:
+ case KeyEvent.KEYCODE_NUM_LOCK:
+ case KeyEvent.KEYCODE_SCROLL_LOCK:
+ case KeyEvent.KEYCODE_COMMA:
+ case KeyEvent.KEYCODE_PERIOD:
+ case KeyEvent.KEYCODE_SLASH:
+ case KeyEvent.KEYCODE_BACKSLASH:
+ case KeyEvent.KEYCODE_SEMICOLON:
+ case KeyEvent.KEYCODE_APOSTROPHE:
+ case KeyEvent.KEYCODE_GRAVE:
+ case KeyEvent.KEYCODE_LEFT_BRACKET:
+ case KeyEvent.KEYCODE_RIGHT_BRACKET:
+ case KeyEvent.KEYCODE_MINUS:
+ case KeyEvent.KEYCODE_EQUALS:
+ return true;
+ default:
+ return false;
+ }
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/MuxRecorder.java b/app/src/main/java/invalid/lena/scrcpy/MuxRecorder.java
new file mode 100644
index 0000000..457272e
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/MuxRecorder.java
@@ -0,0 +1,225 @@
+package invalid.lena.scrcpy;
+
+import android.media.MediaCodec;
+import android.media.MediaFormat;
+import android.media.MediaMuxer;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+// MP4 muxer that taps the H.264/H.265/AV1 elementary stream coming
+// straight from the scrcpy server - no re-encode, the bytes go onto
+// disk verbatim. AV1 muxing into MP4 needs Android 11+ (MediaMuxer
+// learned the codec there); we are minSdk 35 so that's fine.
+//
+// State machine: IDLE → (arm) → ARMED → (next keyframe) → RECORDING
+// → (stop) → IDLE
+//
+// Ordering:
+// onMeta(fcc, w, h) from VideoStream session-meta packets.
+// onFrame(...) every frame, in wire order. Config frames
+// carry SPS/PPS; we cache the most recent one
+// as csd-0 for MediaMuxer.addTrack.
+//
+// Resize during recording closes the file (addTrack-after-start is
+// illegal). User can re-arm; a fresh output gets the new dimensions.
+public final class MuxRecorder implements VideoRecorder {
+
+ public interface Listener {
+ // Fires on the recorder thread; main use is updating the UI's
+ // record button label once writing actually begins (we may be
+ // ARMED for many frames before a keyframe lands).
+ default void onStarted(File out) {}
+ default void onStopped(File out, long bytes) {}
+ default void onError(Throwable t) {}
+ }
+
+ private enum State { IDLE, ARMED, RECORDING, ERROR }
+
+ private final Object lock = new Object();
+ private final Listener listener;
+
+ private State state = State.IDLE;
+ private int fourcc, w, h;
+ private byte[] csd;
+
+ private File outFile;
+ private MediaMuxer muxer;
+ private int trackIdx = -1;
+ private long bytesWritten;
+ private long firstPtsUs = -1;
+ private long lastPtsUs = -1;
+
+ public MuxRecorder() { this(null); }
+
+ public MuxRecorder(Listener listener) {
+ this.listener = listener != null ? listener : new Listener() {};
+ }
+
+ // Externally-driven controls.
+
+ public void arm(File out) {
+ synchronized (lock) {
+ if (state != State.IDLE) {
+ Log.w("rec: arm ignored in state %s", state);
+ return;
+ }
+ outFile = out;
+ state = State.ARMED;
+ firstPtsUs = -1;
+ lastPtsUs = -1;
+ bytesWritten = 0;
+ }
+ Log.i("rec: armed -> %s", out);
+ }
+
+ public void stop() {
+ File out;
+ long bytes;
+ boolean reportStopped;
+ synchronized (lock) {
+ if (state == State.IDLE) return;
+ reportStopped = (state == State.RECORDING);
+ closeMuxerLocked();
+ out = outFile;
+ bytes = bytesWritten;
+ state = State.IDLE;
+ outFile = null;
+ }
+ Log.i("rec: stopped (%d bytes)", bytes);
+ if (reportStopped) listener.onStopped(out, bytes);
+ }
+
+ public boolean isActive() {
+ synchronized (lock) {
+ return state == State.ARMED || state == State.RECORDING;
+ }
+ }
+
+ // VideoRecorder callbacks.
+
+ @Override
+ public void onMeta(int fcc, int width, int height) {
+ synchronized (lock) {
+ // Resize during recording → close, surface as an error so
+ // the UI can re-arm. addTrack after start is illegal; we
+ // don't try to splice tracks together.
+ if (state == State.RECORDING && (fcc != fourcc || width != w || height != h)) {
+ Log.w("rec: resize/codec change during recording - closing");
+ File out = outFile;
+ long bytes = bytesWritten;
+ closeMuxerLocked();
+ state = State.IDLE;
+ outFile = null;
+ listener.onStopped(out, bytes);
+ }
+ fourcc = fcc; w = width; h = height;
+ }
+ }
+
+ @Override
+ public void onFrame(byte[] data, long ptsUs, boolean isConfig, boolean isKeyframe) {
+ if (isConfig) {
+ // Cache the latest CSD (SPS/PPS for h264 - vendor packs as
+ // Annex-B NAL units, exactly what MediaMuxer wants in csd-0
+ // for AVC/HEVC/AV1).
+ synchronized (lock) {
+ csd = data.clone();
+ }
+ return;
+ }
+ synchronized (lock) {
+ switch (state) {
+ case IDLE:
+ case ERROR:
+ return;
+ case ARMED:
+ if (!isKeyframe || csd == null) return;
+ if (!startMuxerLocked()) {
+ state = State.ERROR;
+ return;
+ }
+ writeSampleLocked(data, ptsUs, true);
+ state = State.RECORDING;
+ listener.onStarted(outFile);
+ return;
+ case RECORDING:
+ writeSampleLocked(data, ptsUs, isKeyframe);
+ }
+ }
+ }
+
+ @Override
+ public void close() {
+ stop();
+ }
+
+ // --- internals; all called with `lock` held ---
+
+ private boolean startMuxerLocked() {
+ String mime = mimeFor(fourcc);
+ if (mime == null) {
+ Log.e("rec: unsupported codec %s", Wire.fourccName(fourcc));
+ return false;
+ }
+ try {
+ muxer = new MediaMuxer(outFile.getAbsolutePath(),
+ MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
+ MediaFormat fmt = MediaFormat.createVideoFormat(mime, w, h);
+ fmt.setByteBuffer("csd-0", ByteBuffer.wrap(csd));
+ trackIdx = muxer.addTrack(fmt);
+ muxer.start();
+ Log.i("rec: muxer start %s %dx%d -> %s", mime, w, h, outFile);
+ return true;
+ } catch (IOException | IllegalStateException e) {
+ Log.e(e, "rec: muxer start failed");
+ try { if (muxer != null) muxer.release(); } catch (Exception ignored) {}
+ muxer = null;
+ trackIdx = -1;
+ listener.onError(e);
+ return false;
+ }
+ }
+
+ private void writeSampleLocked(byte[] data, long ptsUs, boolean isKeyframe) {
+ if (muxer == null) return;
+ // PTS is rebased to zero so the file is self-contained; some
+ // players choke on absolute PTS that doesn't start near zero.
+ if (firstPtsUs < 0) firstPtsUs = ptsUs;
+ long rebased = ptsUs - firstPtsUs;
+ if (rebased < 0) rebased = 0;
+ // Monotonic guard - MediaMuxer fails the write if pts goes back.
+ if (rebased <= lastPtsUs) rebased = lastPtsUs + 1;
+ lastPtsUs = rebased;
+
+ try {
+ ByteBuffer buf = ByteBuffer.wrap(data);
+ MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
+ info.set(0, data.length, rebased,
+ isKeyframe ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0);
+ muxer.writeSampleData(trackIdx, buf, info);
+ bytesWritten += data.length;
+ } catch (IllegalStateException e) {
+ Log.w("rec: writeSampleData: %s", e);
+ }
+ }
+
+ private void closeMuxerLocked() {
+ MediaMuxer m = muxer;
+ muxer = null;
+ trackIdx = -1;
+ if (m == null) return;
+ try { m.stop(); } catch (Exception ignored) {}
+ try { m.release(); } catch (Exception ignored) {}
+ }
+
+ private static String mimeFor(int fourcc) {
+ switch (fourcc) {
+ case Wire.CODEC_H264: return MediaFormat.MIMETYPE_VIDEO_AVC;
+ case Wire.CODEC_H265: return MediaFormat.MIMETYPE_VIDEO_HEVC;
+ case Wire.CODEC_AV1: return MediaFormat.MIMETYPE_VIDEO_AV1;
+ default: return null;
+ }
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Server.java b/app/src/main/java/invalid/lena/scrcpy/Server.java
new file mode 100644
index 0000000..cc16b44
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Server.java
@@ -0,0 +1,304 @@
+package invalid.lena.scrcpy;
+
+import android.content.Context;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import io.github.muntashirakon.adb.AdbStream;
+
+// Bring the scrcpy server up on the target:
+// 1. Push assets/scrcpy-server.jar to /data/local/tmp/scrcpy-server.jar
+// via the adb sync protocol.
+// 2. Spawn `app_process / com.genymobile.scrcpy.Server <ver> key=value...`
+// via an adb shell stream and hold it open. cleanup=true means the
+// server exits when we close that stream.
+// 3. Open three localabstract:scrcpy_<scid> streams in order
+// (video, audio, control). With tunnel_forward=true the server is
+// the listener, so we just dial.
+// 4. Drain 1 probe byte + 64-byte device name from the FIRST stream
+// that the server accepts (video, if requested).
+//
+// Returns a Streams record with everything Session needs: typed
+// InputStream/OutputStream wrappers (opened ONCE per AdbStream so the
+// reader's offset is unambiguous), plus the underlying AdbStream
+// handles so close() can release them.
+public final class Server {
+
+ private static final String REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar";
+ private static final String ASSET_JAR = "scrcpy-server.jar";
+ private static final String ASSET_VERSION = "scrcpy-server.version";
+ private static final int FILE_MODE = 0100644; // regular file, 0644
+ // Server forks a CleanUp helper before opening its abstract sockets;
+ // on slow emulators that takes several seconds, so the budget needs
+ // to be generous. Wall-clock bounded: even if every attempt wedges
+ // for the full OPEN_ATTEMPT_TIMEOUT_MS, the total stays near
+ // OPEN_DEADLINE_MS - strictly below the e2e test deadline
+ // (test-rig/e2e.sh's E2E_DEADLINE, default 60 s), or the test
+ // would fail the wrong way.
+ private static final long OPEN_DEADLINE_MS = 20_000;
+ private static final int OPEN_BACKOFF_MS = 100;
+ // Per-attempt timeout for adb.openAbstract. libadb-android's
+ // AdbConnection.open() blocks on a naked stream.wait() with no
+ // loop or timeout - a missed notification (response arrives before
+ // we park) wedges the call forever. Wrap each attempt with a
+ // timeout + interrupt so the loop can move on.
+ private static final long OPEN_ATTEMPT_TIMEOUT_MS = 800;
+
+ public static final class Streams {
+ public final AdbStream videoAds, audioAds, controlAds;
+ public final InputStream videoIn, audioIn, controlIn;
+ public final OutputStream controlOut;
+ public final String deviceName;
+ public final String scid;
+ public final String version;
+
+ Streams(AdbStream va, AdbStream aa, AdbStream ca,
+ InputStream vi, InputStream ai, InputStream ci, OutputStream co,
+ String name, String scid, String version) {
+ this.videoAds = va; this.audioAds = aa; this.controlAds = ca;
+ this.videoIn = vi; this.audioIn = ai; this.controlIn = ci;
+ this.controlOut = co;
+ this.deviceName = name; this.scid = scid; this.version = version;
+ }
+ }
+
+ private final Context ctx;
+ private final Adb adb;
+ private AdbStream shell;
+ private Thread shellPump;
+ private Streams streams;
+ private volatile boolean serverEof;
+
+ public Server(Context ctx, Adb adb) {
+ this.ctx = ctx;
+ this.adb = adb;
+ }
+
+ public Streams bringUp() throws Exception {
+ String version = readVersion();
+ long pushed = push();
+ Log.i("push %s bytes=%d", REMOTE_PATH, pushed);
+
+ String scid = newScid();
+ String cmd = buildCmdline(version, scid);
+ Log.i("spawn server ver=%s scid=%s", version, scid);
+ Log.i("cmdline: %s", cmd);
+
+ shell = adb.openShell(cmd);
+ shellPump = new Thread(() -> pump(shell.openInputStream()), "server-stdout");
+ shellPump.setDaemon(true);
+ shellPump.start();
+
+ AdbStream va = openAbstract(scid);
+ AdbStream aa = openAbstract(scid);
+ AdbStream ca = openAbstract(scid);
+
+ InputStream vi = va.openInputStream();
+ InputStream ai = aa.openInputStream();
+ InputStream ci = ca.openInputStream();
+ OutputStream co = ca.openOutputStream();
+
+ String name = readDeviceMeta(vi);
+ Log.i("device name=%s", name);
+ streams = new Streams(va, aa, ca, vi, ai, ci, co, name, scid, version);
+ return streams;
+ }
+
+ // Idempotent. Closes the three media/control streams first (lets
+ // the wire drain), then the shell (which makes the scrcpy server
+ // exit via cleanup=true), then gives the cleanup helper up to
+ // CLOSE_GRACE_MS to restore device state - display power, screen
+ // timeout, brightness, etc. - before we kill the adb connection
+ // out from under it. Best-effort: cleanup is server-side and we
+ // can't synchronously confirm it.
+ public void close() {
+ if (streams != null) {
+ closeQuietly(streams.videoAds);
+ closeQuietly(streams.audioAds);
+ closeQuietly(streams.controlAds);
+ streams = null;
+ }
+ Thread t = shellPump;
+ AdbStream s = shell;
+ shell = null;
+ shellPump = null;
+ closeQuietly(s);
+ if (t != null) {
+ try { t.join(CLOSE_GRACE_MS); }
+ catch (InterruptedException ignored) {}
+ if (t.isAlive()) t.interrupt();
+ }
+ }
+
+ private static final long CLOSE_GRACE_MS = 500;
+
+ private static void closeQuietly(AdbStream s) {
+ if (s == null) return;
+ try { s.close(); } catch (IOException ignored) {}
+ }
+
+ // ---- helpers ----
+
+ private String readVersion() throws IOException {
+ try (InputStream in = ctx.getAssets().open(ASSET_VERSION)) {
+ byte[] buf = new byte[64];
+ int n = 0, r;
+ while ((r = in.read(buf, n, buf.length - n)) > 0) n += r;
+ String v = new String(buf, 0, n, StandardCharsets.UTF_8).trim();
+ if (v.isEmpty()) {
+ throw new IOException("scrcpy-server.version is empty");
+ }
+ return v;
+ }
+ }
+
+ private static String newScid() {
+ // 31-bit random, 8 lowercase hex chars - matches scrcpy upstream client.
+ int v = new SecureRandom().nextInt() & 0x7fffffff;
+ return String.format("%08x", v);
+ }
+
+ private String buildCmdline(String version, String scid) {
+ String videoCodec = Settings.videoCodec(ctx);
+ String audioCodec = Settings.audioCodec(ctx);
+ int maxSize = Settings.maxSize(ctx);
+ int videoBitR = Settings.videoBitRate(ctx);
+ int maxFps = Settings.maxFps(ctx);
+ List<String> args = new ArrayList<>();
+ args.add("CLASSPATH=" + REMOTE_PATH);
+ args.add("app_process");
+ args.add("/");
+ args.add("com.genymobile.scrcpy.Server");
+ args.add(version);
+ args.add("scid=" + scid);
+ args.add("log_level=info");
+ args.add("video=true");
+ args.add("audio=true");
+ args.add("control=true");
+ args.add("video_codec=" + videoCodec);
+ args.add("audio_codec=" + audioCodec);
+ args.add("max_size=" + maxSize);
+ args.add("video_bit_rate=" + videoBitR);
+ if (maxFps > 0) args.add("max_fps=" + maxFps);
+ args.add("clipboard_autosync=true");
+ args.add("tunnel_forward=true");
+ args.add("cleanup=true");
+ args.add("power_on=true");
+ return String.join(" ", args);
+ }
+
+ private AdbStream openAbstract(String scid) throws Exception {
+ String name = "scrcpy_" + scid;
+ long deadline = System.currentTimeMillis() + OPEN_DEADLINE_MS;
+ Throwable last = null;
+ for (int attempt = 1; System.currentTimeMillis() < deadline; attempt++) {
+ // If the shell stream closed (server died early - usually with
+ // an error printed to stderr that our pump captured), bail
+ // immediately. Otherwise we'd spin out the deadline on a
+ // dead target.
+ if (serverEof) {
+ throw new IOException("server exited before opening "
+ + name + " (see 'server:' logs)",
+ last instanceof Exception ? (Exception) last : null);
+ }
+ try {
+ AdbStream s = openAbstractOnce(name, OPEN_ATTEMPT_TIMEOUT_MS);
+ Log.i("openAbstract %s ok (attempt %d)", name, attempt);
+ return s;
+ } catch (Throwable t) {
+ last = t;
+ Thread.sleep(OPEN_BACKOFF_MS);
+ }
+ }
+ throw new IOException("openAbstract " + name + " failed after "
+ + (OPEN_DEADLINE_MS / 1000) + " s",
+ last instanceof Exception ? (Exception) last : null);
+ }
+
+ // Wraps a single adb.openAbstract call with a hard timeout. The
+ // upstream call can wedge forever on a missed notify in its naked
+ // stream.wait(); we run it on a daemon thread and join with timeout,
+ // interrupt on overrun, and let the caller retry. If the orphaned
+ // call succeeds after we gave up, the stream must be closed:
+ // keeping it would silently consume one of the scrcpy server's
+ // three accepts and shift every later dial off by one.
+ private AdbStream openAbstractOnce(String name, long timeoutMs) throws Exception {
+ AtomicReference<AdbStream> result = new AtomicReference<>();
+ AtomicReference<Throwable> err = new AtomicReference<>();
+ AtomicBoolean abandoned = new AtomicBoolean();
+ Thread t = new Thread(() -> {
+ try {
+ AdbStream s = adb.openAbstract(name);
+ result.set(s);
+ if (abandoned.get()) closeQuietly(s);
+ } catch (Throwable ex) {
+ err.set(ex);
+ }
+ }, "openAbstract-" + name);
+ t.setDaemon(true);
+ t.start();
+ t.join(timeoutMs);
+ if (t.isAlive()) {
+ abandoned.set(true);
+ t.interrupt();
+ // One side of the publish/abandon race closes the stream;
+ // closeQuietly tolerates both doing it.
+ closeQuietly(result.get());
+ throw new IOException("openAbstract " + name + " timed out");
+ }
+ Throwable ex = err.get();
+ if (ex instanceof Exception) throw (Exception) ex;
+ if (ex != null) throw new RuntimeException(ex);
+ return result.get();
+ }
+
+ private static String readDeviceMeta(InputStream in) throws IOException {
+ byte[] probe = new byte[1];
+ Wire.readFully(in, probe);
+ byte[] name = new byte[64];
+ Wire.readFully(in, name);
+ int n = 0;
+ while (n < name.length && name[n] != 0) n++;
+ return new String(name, 0, n, StandardCharsets.UTF_8);
+ }
+
+ private void pump(InputStream in) {
+ try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = r.readLine()) != null) {
+ Log.i("server: %s", line);
+ }
+ } catch (IOException e) {
+ if (!Thread.currentThread().isInterrupted()) Log.w("server-stdout closed: %s", e);
+ } finally {
+ serverEof = true;
+ Log.i("server: stream end");
+ }
+ }
+
+ // ---- adb sync push ----
+
+ private long push() throws Exception {
+ AdbStream sync = adb.openSync();
+ // sync.openInput/OutputStream() return wrapper streams whose close()
+ // is a no-op; the AdbStream itself owns the channel. Close it once
+ // in finally.
+ try (InputStream src = ctx.getAssets().open(ASSET_JAR)) {
+ int mtime = (int)(System.currentTimeMillis() / 1000L);
+ return Sync.push(src, sync.openOutputStream(), sync.openInputStream(),
+ REMOTE_PATH, FILE_MODE, mtime);
+ } finally {
+ try { sync.close(); } catch (IOException ignored) {}
+ }
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Session.java b/app/src/main/java/invalid/lena/scrcpy/Session.java
new file mode 100644
index 0000000..019ce80
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Session.java
@@ -0,0 +1,318 @@
+package invalid.lena.scrcpy;
+
+import android.content.Context;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.Surface;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+// One mirroring session: owns the Adb connection, the spawned scrcpy
+// server, the three streams, sinks, and the controller. start() and
+// stop() are idempotent and may be called from any thread.
+//
+// Listener contract:
+// onConnected(w,h) - wire open, frames flowing. Fires on the first
+// connect and again after each successful auto-reconnect.
+// onReconnecting() - a live link dropped; the bring-up ladder is rerun.
+// Followed by onConnected (recovered) or onError (gave up).
+// onError(t) - fatal: retries exhausted, or non-retriable failure.
+// Always followed by onStopped().
+// onStopped() - final state. Fires exactly once per session, whether
+// user stop() or retries-exhausted.
+//
+// Auto-reconnect: after a successful connect the video socket is watched;
+// if it ends mid-session (target sleep, Wi-Fi blip, server crash) the
+// session reruns the bring-up ladder instead of freezing. Only when that
+// ladder is exhausted does it give up via onError + onStopped.
+//
+// Surface-readiness race: Mirror.surfaceCreated builds the Session and
+// calls start(), but Mirror.surfaceChanged (which carries the view
+// dimensions) can fire before run() has finished constructing Controller.
+// setViewSize() therefore stashes the value and applies it as soon as
+// the Controller is available.
+public final class Session {
+
+ public interface Listener {
+ // Fires the first time the server reports a session-meta packet
+ // (wire open, frames about to flow), and again after each
+ // successful auto-reconnect. Useful for the activity's status bar.
+ default void onConnected(int w, int h) {}
+ // A previously-live link dropped and we are bringing it back up.
+ // Followed by onConnected (recovered), onError (gave up), or
+ // onStopped alone (user stop() raced the reconnect).
+ default void onReconnecting() {}
+ void onError(Throwable t);
+ void onStopped();
+ }
+
+ // Bring-up retry budget. Total worst-case wait ~ sum of these
+ // delays + per-attempt bring-up time. Keep below e2e.sh's deadline.
+ private static final long[] BACKOFF_MS = {0L, 1_500L, 5_000L};
+
+ private final Context ctx;
+ private final Adb adb;
+ private final Devices.Device target;
+ private volatile Surface surface;
+ private final Listener listener;
+
+ // Lightweight counters for the in-app status overlay; readers reach
+ // into the sinks/controller directly.
+ public long videoFrames() { VideoSink v = videoSink; return v == null ? 0 : v.frames; }
+ public long audioFrames() { AudioSink a = audioSink; return a == null ? 0 : a.frames; }
+ public String lastEvent() { Controller c = controller; return c == null ? "(idle)" : c.lastEvent; }
+
+ private Server server;
+ private VideoStream videoStream;
+ private VideoSink videoSink;
+ private AudioStream audioStream;
+ private AudioSink audioSink;
+ private ControlStream controlStream;
+ private Controller controller;
+ private MuxRecorder recorder;
+ private Thread runner;
+ private volatile boolean stopped;
+ private volatile int pendingViewW, pendingViewH;
+
+ // Counted down by the video reader when its loop exits; the session
+ // thread parks on it for the live duration of a connection. Swapped
+ // for a fresh latch on each reconnect cycle.
+ private volatile CountDownLatch endSignal = new CountDownLatch(1);
+
+ public Session(Context ctx, Adb adb, Devices.Device target, Surface surface, Listener listener) {
+ this.ctx = ctx;
+ this.adb = adb;
+ this.target = target;
+ this.surface = surface;
+ this.listener = listener;
+ }
+
+ public synchronized void start() {
+ if (runner != null) return;
+ runner = new Thread(this::run, "session");
+ runner.start();
+ }
+
+ public synchronized void stop() {
+ if (stopped) return;
+ stopped = true;
+ Log.i("session: stop");
+ tearDownInstalled();
+ if (listener != null) listener.onStopped();
+ }
+
+ // ---- input forwarding (Controller stays internal) ----
+
+ public void onTouch(MotionEvent ev) {
+ Controller c = controller;
+ if (c != null) c.onTouch(ev);
+ }
+
+ public void onKey(KeyEvent ev) {
+ Controller c = controller;
+ if (c != null) c.onKey(ev);
+ }
+
+ public void onBack() {
+ Controller c = controller;
+ if (c != null) c.onBack();
+ }
+
+ public void setViewSize(int w, int h) {
+ pendingViewW = w; pendingViewH = h;
+ Controller c = controller;
+ if (c != null) c.setViewSize(w, h);
+ }
+
+ // ---- recording (delegates to MuxRecorder) ----
+
+ public void armRecording(File out) {
+ MuxRecorder r = recorder;
+ if (r == null) {
+ Log.w("session: armRecording before bring-up - ignored");
+ return;
+ }
+ r.arm(out);
+ }
+
+ public void stopRecording() {
+ MuxRecorder r = recorder;
+ if (r != null) r.stop();
+ }
+
+ public boolean isRecording() {
+ MuxRecorder r = recorder;
+ return r != null && r.isActive();
+ }
+
+ // Swap the surface the video pipeline draws to. The audio and control
+ // sides keep streaming, so audio + clipboard still work while the
+ // activity is backgrounded. Pass null to detach; pass a new Surface
+ // (from a recreated SurfaceView) to resume rendering.
+ public void swapSurface(Surface s) {
+ surface = s;
+ VideoSink vk = videoSink;
+ if (vk != null) vk.setOutputSurface(s);
+ }
+
+ // ---- internals ----
+
+ // Supervisor loop: connect, park until the live pipeline dies, and
+ // reconnect if the death wasn't a user stop(). Runs on one thread for
+ // the whole session lifetime.
+ private void run() {
+ while (!stopped) {
+ if (!connect()) return; // gave up: onError + onStopped fired
+ try {
+ endSignal.await(); // park until the pipeline dies or stop()
+ } catch (InterruptedException ie) {
+ return;
+ }
+ // Decide teardown-and-retry under the monitor so a concurrent
+ // stop() can't interleave: without this, onReconnecting()
+ // could fire after stop()'s onStopped(), breaking the
+ // listener contract.
+ synchronized (this) {
+ if (stopped) return; // user stop()
+ Log.i("session: video stream ended - link lost, reconnecting");
+ tearDownInstalled();
+ endSignal = new CountDownLatch(1);
+ if (listener != null) listener.onReconnecting();
+ }
+ }
+ }
+
+ // Run the bring-up retry ladder once. Returns true when a session is
+ // live (read threads started); false if the budget was exhausted, in
+ // which case onError() + onStopped() have already fired.
+ private boolean connect() {
+ Throwable lastErr = null;
+ for (int attempt = 0; attempt < BACKOFF_MS.length && !stopped; attempt++) {
+ if (BACKOFF_MS[attempt] > 0) {
+ Log.i("session: retry %d/%d after %d ms",
+ attempt + 1, BACKOFF_MS.length, BACKOFF_MS[attempt]);
+ try { Thread.sleep(BACKOFF_MS[attempt]); }
+ catch (InterruptedException ie) { return false; }
+ if (stopped) return false;
+ }
+ try {
+ bringUp();
+ return true; // success; the read threads own the live session
+ } catch (InterruptedException ie) {
+ return false;
+ } catch (Throwable t) {
+ Log.w("session: bring-up attempt %d/%d failed: %s",
+ attempt + 1, BACKOFF_MS.length, t);
+ lastErr = t;
+ // Discard any partial state from this attempt before retrying.
+ tearDownInstalled();
+ }
+ }
+ if (stopped) return false;
+ Log.e(lastErr, "session: gave up after %d attempts", BACKOFF_MS.length);
+ Throwable err = lastErr;
+ synchronized (this) {
+ if (stopped) return false;
+ stopped = true;
+ tearDownInstalled();
+ }
+ if (listener != null) {
+ listener.onError(err);
+ listener.onStopped();
+ }
+ return false;
+ }
+
+ // Build everything into locals first. Then install + start under the
+ // monitor - but only if stop() hasn't already fired, in which case
+ // we tear down the locals we just built so nothing leaks.
+ private void bringUp() throws Exception {
+ Log.i("session: connect %s:%d", target.host, target.port);
+ adb.disconnect();
+ if (!adb.connect(target.host, target.port)) {
+ throw new IOException("adb connect returned false");
+ }
+ Log.i("adb connect ok");
+
+ Server srv = new Server(ctx, adb);
+ Server.Streams s = srv.bringUp();
+
+ ControlStream cs = new ControlStream(s.controlIn, s.controlOut);
+ Controller ctrl = new Controller(ctx, cs::send);
+ cs.setInboundSink(ctrl);
+
+ AudioSink ak = new AudioSink();
+ AudioStream as = new AudioStream(s.audioIn, ak);
+
+ VideoSink vk = new VideoSink(surface);
+ final Controller ctrlRef = ctrl; // capture for SizeListener
+ AtomicBoolean reported = new AtomicBoolean();
+ VideoStream vs = new VideoStream(s.videoIn, vk, (w, h) -> {
+ ctrlRef.setTargetSize(w, h);
+ if (reported.compareAndSet(false, true) && listener != null) {
+ listener.onConnected(w, h);
+ }
+ });
+ vs.setOnEnd(this::onVideoEnded);
+ MuxRecorder rec = new MuxRecorder();
+ vs.setRecorder(rec);
+
+ synchronized (this) {
+ if (stopped) {
+ tearDownLocals(srv, cs, ctrl, ak, as, vk, vs, rec);
+ throw new IOException("session: stopped during bring-up");
+ }
+ server = srv;
+ controlStream = cs;
+ controller = ctrl;
+ audioSink = ak;
+ audioStream = as;
+ videoSink = vk;
+ videoStream = vs;
+ recorder = rec;
+ if (pendingViewW > 0) ctrl.setViewSize(pendingViewW, pendingViewH);
+ }
+
+ cs.start();
+ as.start();
+ vs.start();
+ }
+
+ // Fired on the video-reader thread when its read loop exits (EOF,
+ // error, or stop()). The video socket is the authoritative stream;
+ // its end wakes the supervisor, which either unwinds (stop) or
+ // reconnects. A no-op countdown after stop() is harmless.
+ private void onVideoEnded() {
+ endSignal.countDown();
+ }
+
+ private synchronized void tearDownInstalled() {
+ tearDownLocals(server, controlStream, controller,
+ audioSink, audioStream, videoSink, videoStream, recorder);
+ server = null;
+ controlStream = null;
+ controller = null;
+ audioSink = null;
+ audioStream = null;
+ videoSink = null;
+ videoStream = null;
+ recorder = null;
+ try { adb.disconnect(); } catch (Exception ignored) {}
+ }
+
+ private static void tearDownLocals(Server srv, ControlStream cs, Controller ctrl,
+ AudioSink ak, AudioStream as,
+ VideoSink vk, VideoStream vs, MuxRecorder rec) {
+ if (rec != null) rec.close();
+ if (vs != null) vs.stop();
+ if (vk != null) vk.release();
+ if (as != null) as.stop();
+ if (ak != null) ak.release();
+ if (cs != null) cs.stop();
+ if (ctrl != null) ctrl.release();
+ if (srv != null) srv.close();
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Sessions.java b/app/src/main/java/invalid/lena/scrcpy/Sessions.java
new file mode 100644
index 0000000..95c0a1f
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Sessions.java
@@ -0,0 +1,75 @@
+package invalid.lena.scrcpy;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Intent;
+import android.content.pm.ServiceInfo;
+import android.os.IBinder;
+
+// Foreground service whose only job is to keep the app process alive
+// while the user is mirroring. Mirror starts it on entry and stops it
+// in onDestroy; the running foreground service (with its notification)
+// is what keeps the OOM killer away, so the activity can survive being
+// briefly backgrounded (rotation, IME, swipe-to-home) without the
+// scrcpy server tearing down.
+//
+// Type is FOREGROUND_SERVICE_DATA_SYNC: we are pulling a continuous
+// data stream (encoded video + raw PCM) from another device.
+//
+// No binder API - the service does not own Session. Mirror owns it.
+public final class Sessions extends Service {
+
+ private static final String CHANNEL_ID = "scrcpy-android-session";
+ private static final int NOTIF_ID = 1;
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ Log.i("sessions: onCreate");
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ ensureChannel();
+ Notification n = new Notification.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(getString(R.string.app_name))
+ .setContentText(getString(R.string.notif_session_active))
+ .setContentIntent(reopenIntent())
+ .setOngoing(true)
+ .build();
+ startForeground(NOTIF_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
+ return START_NOT_STICKY;
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override
+ public void onDestroy() {
+ Log.i("sessions: onDestroy");
+ super.onDestroy();
+ }
+
+ private void ensureChannel() {
+ NotificationManager nm = getSystemService(NotificationManager.class);
+ if (nm.getNotificationChannel(CHANNEL_ID) != null) return;
+ NotificationChannel ch = new NotificationChannel(
+ CHANNEL_ID, getString(R.string.app_name),
+ NotificationManager.IMPORTANCE_LOW);
+ ch.setDescription(getString(R.string.notif_session_active));
+ nm.createNotificationChannel(ch);
+ }
+
+ private PendingIntent reopenIntent() {
+ Intent i = new Intent(this, Main.class);
+ i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ return PendingIntent.getActivity(this, 0, i,
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Settings.java b/app/src/main/java/invalid/lena/scrcpy/Settings.java
new file mode 100644
index 0000000..c4bb83b
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Settings.java
@@ -0,0 +1,93 @@
+package invalid.lena.scrcpy;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+// Thin wrapper around SharedPreferences for the small set of user
+// choices the app exposes. No reactivity, no DataStore - the values
+// are read once when a Session starts and applied to the scrcpy
+// server command line.
+public final class Settings {
+
+ private static final String FILE = "scrcpy-android";
+
+ public static final String VIDEO_CODEC = "video_codec";
+ public static final String AUDIO_CODEC = "audio_codec";
+ public static final String MAX_SIZE = "max_size"; // px, long edge; 0 = no cap
+ public static final String VIDEO_BIT_RATE = "video_bit_rate"; // bits/sec
+ public static final String MAX_FPS = "max_fps"; // fps; 0 = unlimited
+ public static final String HINT_BACK_SHOWN = "hint_back_shown"; // first-run UI hint
+ public static final String STATUS_BAR = "status_bar"; // show status bar while mirroring
+
+ public static final String DEFAULT_VIDEO_CODEC = "h264";
+ public static final String DEFAULT_AUDIO_CODEC = "raw";
+ public static final int DEFAULT_MAX_SIZE = 0;
+ public static final int DEFAULT_VIDEO_BIT_RATE = 8_000_000;
+ public static final int DEFAULT_MAX_FPS = 0;
+ // Hidden while mirroring by default: an always-on bar over the video
+ // is intrusive. It still auto-shows when not CONNECTED so Reconnect
+ // stays reachable. Record lives in the bar, so enable this to keep it.
+ public static final boolean DEFAULT_STATUS_BAR = false;
+
+ private Settings() {}
+
+ public static SharedPreferences prefs(Context ctx) {
+ return ctx.getApplicationContext().getSharedPreferences(FILE, Context.MODE_PRIVATE);
+ }
+
+ public static String videoCodec(Context ctx) {
+ return prefs(ctx).getString(VIDEO_CODEC, DEFAULT_VIDEO_CODEC);
+ }
+
+ public static String audioCodec(Context ctx) {
+ return prefs(ctx).getString(AUDIO_CODEC, DEFAULT_AUDIO_CODEC);
+ }
+
+ public static int maxSize(Context ctx) {
+ return prefs(ctx).getInt(MAX_SIZE, DEFAULT_MAX_SIZE);
+ }
+
+ public static int videoBitRate(Context ctx) {
+ return prefs(ctx).getInt(VIDEO_BIT_RATE, DEFAULT_VIDEO_BIT_RATE);
+ }
+
+ public static int maxFps(Context ctx) {
+ return prefs(ctx).getInt(MAX_FPS, DEFAULT_MAX_FPS);
+ }
+
+ public static void setVideoCodec(Context ctx, String v) {
+ prefs(ctx).edit().putString(VIDEO_CODEC, v).apply();
+ }
+
+ public static void setAudioCodec(Context ctx, String a) {
+ prefs(ctx).edit().putString(AUDIO_CODEC, a).apply();
+ }
+
+ public static void setMaxSize(Context ctx, int v) {
+ prefs(ctx).edit().putInt(MAX_SIZE, v).apply();
+ }
+
+ public static void setVideoBitRate(Context ctx, int v) {
+ prefs(ctx).edit().putInt(VIDEO_BIT_RATE, v).apply();
+ }
+
+ public static void setMaxFps(Context ctx, int v) {
+ prefs(ctx).edit().putInt(MAX_FPS, v).apply();
+ }
+
+ public static boolean hintBackShown(Context ctx) {
+ return prefs(ctx).getBoolean(HINT_BACK_SHOWN, false);
+ }
+
+ public static void setHintBackShown(Context ctx, boolean v) {
+ prefs(ctx).edit().putBoolean(HINT_BACK_SHOWN, v).apply();
+ }
+
+ public static boolean showStatusBar(Context ctx) {
+ return prefs(ctx).getBoolean(STATUS_BAR, DEFAULT_STATUS_BAR);
+ }
+
+ public static void setShowStatusBar(Context ctx, boolean v) {
+ prefs(ctx).edit().putBoolean(STATUS_BAR, v).apply();
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java b/app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java
new file mode 100644
index 0000000..d95ecba
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java
@@ -0,0 +1,110 @@
+package invalid.lena.scrcpy;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.widget.CheckBox;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+
+// Codec choice + streaming knobs. All persisted to SharedPreferences
+// via Settings on click; the next session bringup reads them and
+// applies to the scrcpy server cmdline.
+public final class SettingsActivity extends Activity {
+
+ @Override
+ protected void onCreate(Bundle saved) {
+ super.onCreate(saved);
+ setContentView(R.layout.settings);
+ setTitle(R.string.settings);
+
+ RadioGroup videoGroup = findViewById(R.id.video_codec);
+ RadioGroup audioGroup = findViewById(R.id.audio_codec);
+ RadioGroup maxSizeGroup = findViewById(R.id.max_size);
+ RadioGroup bitRateGroup = findViewById(R.id.video_bit_rate);
+ RadioGroup maxFpsGroup = findViewById(R.id.max_fps);
+ CheckBox statusBarBox = findViewById(R.id.show_status_bar);
+
+ switch (Settings.videoCodec(this)) {
+ case "h265": ((RadioButton) findViewById(R.id.video_h265)).setChecked(true); break;
+ case "av1": ((RadioButton) findViewById(R.id.video_av1)).setChecked(true); break;
+ default: ((RadioButton) findViewById(R.id.video_h264)).setChecked(true);
+ }
+ switch (Settings.audioCodec(this)) {
+ case "opus": ((RadioButton) findViewById(R.id.audio_opus)).setChecked(true); break;
+ default: ((RadioButton) findViewById(R.id.audio_raw)).setChecked(true);
+ }
+ switch (Settings.maxSize(this)) {
+ case 480: ((RadioButton) findViewById(R.id.max_size_480)).setChecked(true); break;
+ case 720: ((RadioButton) findViewById(R.id.max_size_720)).setChecked(true); break;
+ case 1080: ((RadioButton) findViewById(R.id.max_size_1080)).setChecked(true); break;
+ case 1440: ((RadioButton) findViewById(R.id.max_size_1440)).setChecked(true); break;
+ case 2160: ((RadioButton) findViewById(R.id.max_size_2160)).setChecked(true); break;
+ default: ((RadioButton) findViewById(R.id.max_size_0)).setChecked(true);
+ }
+ switch (Settings.videoBitRate(this)) {
+ case 1_000_000: ((RadioButton) findViewById(R.id.bit_rate_1m)).setChecked(true); break;
+ case 2_000_000: ((RadioButton) findViewById(R.id.bit_rate_2m)).setChecked(true); break;
+ case 4_000_000: ((RadioButton) findViewById(R.id.bit_rate_4m)).setChecked(true); break;
+ case 16_000_000: ((RadioButton) findViewById(R.id.bit_rate_16m)).setChecked(true); break;
+ default: ((RadioButton) findViewById(R.id.bit_rate_8m)).setChecked(true);
+ }
+ switch (Settings.maxFps(this)) {
+ case 30: ((RadioButton) findViewById(R.id.max_fps_30)).setChecked(true); break;
+ case 60: ((RadioButton) findViewById(R.id.max_fps_60)).setChecked(true); break;
+ case 90: ((RadioButton) findViewById(R.id.max_fps_90)).setChecked(true); break;
+ case 120: ((RadioButton) findViewById(R.id.max_fps_120)).setChecked(true); break;
+ default: ((RadioButton) findViewById(R.id.max_fps_0)).setChecked(true);
+ }
+
+ videoGroup.setOnCheckedChangeListener((g, id) -> {
+ String v = "h264";
+ if (id == R.id.video_h265) v = "h265";
+ else if (id == R.id.video_av1) v = "av1";
+ Settings.setVideoCodec(this, v);
+ Log.i("settings: video_codec=%s", v);
+ });
+
+ audioGroup.setOnCheckedChangeListener((g, id) -> {
+ String a = id == R.id.audio_opus ? "opus" : "raw";
+ Settings.setAudioCodec(this, a);
+ Log.i("settings: audio_codec=%s", a);
+ });
+
+ maxSizeGroup.setOnCheckedChangeListener((g, id) -> {
+ int v = 0;
+ if (id == R.id.max_size_480) v = 480;
+ else if (id == R.id.max_size_720) v = 720;
+ else if (id == R.id.max_size_1080) v = 1080;
+ else if (id == R.id.max_size_1440) v = 1440;
+ else if (id == R.id.max_size_2160) v = 2160;
+ Settings.setMaxSize(this, v);
+ Log.i("settings: max_size=%d", v);
+ });
+
+ bitRateGroup.setOnCheckedChangeListener((g, id) -> {
+ int v = Settings.DEFAULT_VIDEO_BIT_RATE;
+ if (id == R.id.bit_rate_1m) v = 1_000_000;
+ else if (id == R.id.bit_rate_2m) v = 2_000_000;
+ else if (id == R.id.bit_rate_4m) v = 4_000_000;
+ else if (id == R.id.bit_rate_16m) v = 16_000_000;
+ Settings.setVideoBitRate(this, v);
+ Log.i("settings: video_bit_rate=%d", v);
+ });
+
+ maxFpsGroup.setOnCheckedChangeListener((g, id) -> {
+ int v = 0;
+ if (id == R.id.max_fps_30) v = 30;
+ else if (id == R.id.max_fps_60) v = 60;
+ else if (id == R.id.max_fps_90) v = 90;
+ else if (id == R.id.max_fps_120) v = 120;
+ Settings.setMaxFps(this, v);
+ Log.i("settings: max_fps=%d", v);
+ });
+
+ statusBarBox.setChecked(Settings.showStatusBar(this));
+ statusBarBox.setOnCheckedChangeListener((b, checked) -> {
+ Settings.setShowStatusBar(this, checked);
+ Log.i("settings: status_bar=%b", checked);
+ });
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Sync.java b/app/src/main/java/invalid/lena/scrcpy/Sync.java
new file mode 100644
index 0000000..6e37523
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Sync.java
@@ -0,0 +1,78 @@
+package invalid.lena.scrcpy;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+
+// Pure-java adb sync v1 SEND framing. Extracted from Server.push() so
+// it can be unit-tested without an AdbStream or any android coupling.
+//
+// Layout on the wire (little-endian uint32 lengths):
+// "SEND" | u32(len(path,mode)) | path,mode
+// "DATA" | u32(chunk_size) | chunk_bytes (repeats, chunk <= 64K)
+// "DONE" | u32(mtime_seconds)
+// ----> response:
+// "OKAY" | u32(0)
+// or
+// "FAIL" | u32(msg_len) | msg
+public final class Sync {
+
+ public static final int CHUNK = 64 * 1024;
+
+ // FAIL responses carry a short human-readable message; cap what a
+ // corrupt or hostile length field can make us allocate.
+ private static final int MAX_FAIL_MSG = 4 * 1024;
+
+ private Sync() {}
+
+ // Push `src` to `remotePath` with the given mode and mtime, using the
+ // already-open `out` and `in` of an adb sync stream. Returns the
+ // total payload byte count.
+ public static long push(InputStream src, OutputStream out, InputStream in,
+ String remotePath, int mode, int mtimeSec)
+ throws IOException {
+ String header = remotePath + "," + mode;
+ byte[] hb = header.getBytes(StandardCharsets.UTF_8);
+
+ byte[] tag = new byte[8];
+ putTag(tag, 0, "SEND");
+ Wire.writeLe32(tag, 4, hb.length);
+ out.write(tag);
+ out.write(hb);
+
+ byte[] chunk = new byte[CHUNK];
+ byte[] dataHdr = new byte[8];
+ putTag(dataHdr, 0, "DATA");
+ long total = 0;
+ int n;
+ while ((n = src.read(chunk)) > 0) {
+ Wire.writeLe32(dataHdr, 4, n);
+ out.write(dataHdr);
+ out.write(chunk, 0, n);
+ total += n;
+ }
+
+ byte[] done = new byte[8];
+ putTag(done, 0, "DONE");
+ Wire.writeLe32(done, 4, mtimeSec);
+ out.write(done);
+ out.flush();
+
+ byte[] resp = new byte[8];
+ Wire.readFully(in, resp);
+ String code = new String(resp, 0, 4, StandardCharsets.US_ASCII);
+ int len = Wire.readLe32(resp, 4);
+ if ("OKAY".equals(code)) return total;
+
+ byte[] msg = new byte[Math.min(Math.max(0, len), MAX_FAIL_MSG)];
+ if (msg.length > 0) Wire.readFully(in, msg);
+ throw new IOException("sync " + code + ": "
+ + new String(msg, StandardCharsets.UTF_8));
+ }
+
+ private static void putTag(byte[] dst, int off, String tag) {
+ byte[] b = tag.getBytes(StandardCharsets.US_ASCII);
+ System.arraycopy(b, 0, dst, off, 4);
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/VideoFrames.java b/app/src/main/java/invalid/lena/scrcpy/VideoFrames.java
new file mode 100644
index 0000000..cfffe90
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/VideoFrames.java
@@ -0,0 +1,17 @@
+package invalid.lena.scrcpy;
+
+import java.io.IOException;
+
+// Sink for parsed video frames. VideoSink is the only production
+// implementation; tests use a recording stub. Kept android-free so
+// it can be referenced from JVM-only test code.
+public interface VideoFrames {
+
+ void configure(int codecFourcc, int width, int height) throws IOException;
+
+ void reconfigure(int codecFourcc, int width, int height) throws IOException;
+
+ void feed(byte[] data, long ptsUs, boolean isConfig);
+
+ void release();
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/VideoRecorder.java b/app/src/main/java/invalid/lena/scrcpy/VideoRecorder.java
new file mode 100644
index 0000000..98a5ca9
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/VideoRecorder.java
@@ -0,0 +1,19 @@
+package invalid.lena.scrcpy;
+
+// Optional tap on VideoStream. Receives the same frames the decoder
+// gets - same bytes, same order - plus the keyframe bit so a downstream
+// muxer can mark random-access samples. Android-free so VideoStream
+// stays unit-testable; MuxRecorder is the concrete Android impl.
+//
+// Wire order:
+// onMeta(fourcc, w, h) - once per session, again on resize
+// onFrame(data, pts, cfg, key) - for every frame
+// close() - when the stream tears down
+public interface VideoRecorder {
+
+ void onMeta(int codecFourcc, int width, int height);
+
+ void onFrame(byte[] data, long ptsUs, boolean isConfig, boolean isKeyframe);
+
+ void close();
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/VideoSink.java b/app/src/main/java/invalid/lena/scrcpy/VideoSink.java
new file mode 100644
index 0000000..fa8248b
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/VideoSink.java
@@ -0,0 +1,244 @@
+package invalid.lena.scrcpy;
+
+import android.media.MediaCodec;
+import android.media.MediaFormat;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.view.Surface;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Iterator;
+
+// MediaCodec async-mode video decoder writing to a Surface.
+//
+// MediaCodec hands us input buffer indices on its callback handler;
+// VideoStream pushes encoded frames at us synchronously. The two ends
+// meet through a small queue of pending frames waiting for input buffers,
+// plus a corresponding pool of free input buffer indices.
+//
+// Back-pressure policy: when both queues fill, drop the *next* incoming
+// frame unless it is a config or keyframe (we have no way to know its
+// type from outside). For v1 we simply drop oldest pending non-keyframes
+// if the pending queue grows past a small bound.
+//
+// Output timing: releaseOutputBuffer is called with an absolute nano
+// timestamp on the System.nanoTime clock, derived from the wire PTS so
+// the surface composer interpolates frames against vsync instead of
+// rendering them as fast as they decode. Anchor is set on the first
+// frame: wallClockNs0 - ptsUs0 * 1000 = constant offset, then for each
+// subsequent buffer renderAt = ptsUs * 1000 + offset.
+public final class VideoSink implements VideoFrames {
+
+ private static final int MAX_PENDING = 8;
+
+ private volatile Surface surface;
+ public volatile long frames; // public read for the status overlay
+ private MediaCodec codec;
+ private HandlerThread handlerThread;
+ private Handler handler;
+
+ private final Object lock = new Object();
+ private final Deque<Integer> freeInputs = new ArrayDeque<>(16);
+ private final Deque<Frame> pending = new ArrayDeque<>(MAX_PENDING);
+
+ private boolean released;
+ private long ptsOffsetNs; // wall-ns = ptsUs * 1000 + ptsOffsetNs
+ private boolean ptsAnchored;
+
+ private static final class Frame {
+ final byte[] data;
+ final long ptsUs;
+ final boolean isConfig;
+ Frame(byte[] d, long pts, boolean cfg) { data = d; ptsUs = pts; isConfig = cfg; }
+ }
+
+ public VideoSink(Surface surface) {
+ this.surface = surface;
+ }
+
+ // Swap the output Surface without rebuilding MediaCodec. Passing null
+ // detaches output: the codec keeps decoding but discards frames so the
+ // wire stays drained while the activity is backgrounded. Passing a new
+ // Surface re-attaches and rendering resumes from the next decoded frame.
+ public void setOutputSurface(Surface newSurface) {
+ synchronized (lock) {
+ if (released) return;
+ this.surface = newSurface;
+ MediaCodec c = codec;
+ if (c == null) return; // not yet configured; new surface will be used at configure time
+ try {
+ if (newSurface != null) c.setOutputSurface(newSurface);
+ // Calling setOutputSurface(null) is unsupported on some
+ // codecs; instead, ignore output buffers in the callback
+ // when surface is null (see onOutputBufferAvailable).
+ } catch (IllegalStateException e) {
+ Log.w("video sink: setOutputSurface: %s", e);
+ }
+ // Re-anchor PTS so frames decoded against the old surface clock
+ // don't drag the new surface's render time into the past.
+ ptsAnchored = false;
+ }
+ }
+
+ @Override
+ public void configure(int codecFourcc, int width, int height) throws IOException {
+ String mime = mimeFor(codecFourcc);
+ if (mime == null) throw new IOException("unsupported video codec " + Wire.fourccName(codecFourcc));
+ Log.i("video sink: configure mime=%s %dx%d", mime, width, height);
+
+ handlerThread = new HandlerThread("video-mc");
+ handlerThread.start();
+ handler = new Handler(handlerThread.getLooper());
+
+ codec = MediaCodec.createDecoderByType(mime);
+ codec.setCallback(new MediaCodec.Callback() {
+ @Override public void onInputBufferAvailable(MediaCodec mc, int idx) {
+ onFreeInput(idx);
+ }
+ @Override public void onOutputBufferAvailable(MediaCodec mc, int idx, MediaCodec.BufferInfo info) {
+ try {
+ if (surface == null) {
+ // Activity backgrounded - discard output instead
+ // of rendering to a dead surface.
+ mc.releaseOutputBuffer(idx, false);
+ return;
+ }
+ // PTS-honoured render: SurfaceFlinger queues the buffer
+ // for renderTimestampNs and interpolates against vsync,
+ // so bursty arrivals smooth out instead of judder.
+ // Config frames (pts=0) fall through to render-immediately.
+ long renderAtNs = renderTimeNs(info.presentationTimeUs);
+ if (renderAtNs == 0L) mc.releaseOutputBuffer(idx, true);
+ else mc.releaseOutputBuffer(idx, renderAtNs);
+ } catch (IllegalStateException ignored) {}
+ }
+ @Override public void onError(MediaCodec mc, MediaCodec.CodecException e) {
+ Log.e(e, "video sink: codec error");
+ }
+ @Override public void onOutputFormatChanged(MediaCodec mc, MediaFormat fmt) {
+ Log.i("video sink: output format %s", fmt);
+ }
+ }, handler);
+
+ MediaFormat fmt = MediaFormat.createVideoFormat(mime, width, height);
+ codec.configure(fmt, surface, null, 0);
+ codec.start();
+ }
+
+ // Called by VideoStream for every encoded frame, in order.
+ @Override
+ public void feed(byte[] data, long ptsUs, boolean isConfig) {
+ if (!isConfig) frames++;
+ synchronized (lock) {
+ if (released) return;
+ // Try to drain immediately if there's a free input.
+ while (!pending.isEmpty() && !freeInputs.isEmpty()) {
+ submit(pending.pollFirst(), freeInputs.pollFirst());
+ }
+ if (!freeInputs.isEmpty()) {
+ submit(new Frame(data, ptsUs, isConfig), freeInputs.pollFirst());
+ return;
+ }
+ // Queue, with bounded drop policy on non-config frames.
+ if (pending.size() >= MAX_PENDING && !isConfig) {
+ // Drop the oldest non-config frame to avoid stalling
+ // forever. Config frames must survive: the decoder
+ // cannot start without its CSD.
+ for (Iterator<Frame> it = pending.iterator(); it.hasNext(); ) {
+ if (!it.next().isConfig) { it.remove(); break; }
+ }
+ }
+ pending.offerLast(new Frame(data, ptsUs, isConfig));
+ }
+ }
+
+ @Override
+ public void release() {
+ synchronized (lock) {
+ if (released) return;
+ released = true;
+ }
+ teardownCodec();
+ }
+
+ // Tear down the current decoder and reconfigure with new dimensions.
+ // The next CSD frame on the wire (server resets on resize) will prime
+ // the new codec instance.
+ @Override
+ public void reconfigure(int codecFourcc, int width, int height) throws IOException {
+ synchronized (lock) {
+ if (released) return;
+ }
+ teardownCodec();
+ synchronized (lock) {
+ freeInputs.clear();
+ pending.clear();
+ ptsAnchored = false; // re-anchor on the first frame of the new run
+ }
+ configure(codecFourcc, width, height);
+ }
+
+ private void teardownCodec() {
+ MediaCodec c = codec;
+ codec = null;
+ HandlerThread ht = handlerThread;
+ handlerThread = null;
+ handler = null;
+ if (c != null) {
+ try { c.stop(); } catch (Exception ignored) {}
+ try { c.release(); } catch (Exception ignored) {}
+ }
+ if (ht != null) ht.quitSafely();
+ }
+
+ // Internal - runs on the MediaCodec callback thread.
+ private void onFreeInput(int idx) {
+ synchronized (lock) {
+ if (released) return;
+ if (!pending.isEmpty()) submit(pending.pollFirst(), idx);
+ else freeInputs.offerLast(idx);
+ }
+ }
+
+ // Must be called with `lock` held.
+ private void submit(Frame f, int idx) {
+ try {
+ ByteBuffer buf = codec.getInputBuffer(idx);
+ if (buf == null) return;
+ buf.clear();
+ buf.put(f.data);
+ int flags = f.isConfig ? MediaCodec.BUFFER_FLAG_CODEC_CONFIG : 0;
+ codec.queueInputBuffer(idx, 0, f.data.length, f.ptsUs, flags);
+ } catch (IllegalStateException e) {
+ Log.w("video sink: queueInputBuffer: %s", e);
+ }
+ }
+
+ // Convert a wire PTS (microseconds since some scrcpy epoch) into a
+ // System.nanoTime value the surface composer should render at.
+ // First call anchors the offset to "now" so latency stays whatever
+ // the wire produced. PTS=0 (config frames) and unanchored state
+ // both return 0 → caller falls back to render-immediately.
+ private long renderTimeNs(long ptsUs) {
+ if (ptsUs <= 0L) return 0L;
+ synchronized (lock) {
+ if (!ptsAnchored) {
+ ptsOffsetNs = System.nanoTime() - ptsUs * 1000L;
+ ptsAnchored = true;
+ }
+ return ptsUs * 1000L + ptsOffsetNs;
+ }
+ }
+
+ private static String mimeFor(int fourcc) {
+ switch (fourcc) {
+ case Wire.CODEC_H264: return MediaFormat.MIMETYPE_VIDEO_AVC;
+ case Wire.CODEC_H265: return MediaFormat.MIMETYPE_VIDEO_HEVC;
+ case Wire.CODEC_AV1: return MediaFormat.MIMETYPE_VIDEO_AV1;
+ default: return null;
+ }
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/VideoStream.java b/app/src/main/java/invalid/lena/scrcpy/VideoStream.java
new file mode 100644
index 0000000..4eb62d3
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/VideoStream.java
@@ -0,0 +1,156 @@
+package invalid.lena.scrcpy;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+// Reads the scrcpy video socket and drives a VideoFrames sink.
+//
+// Wire format (big-endian, scrcpy 3.x/4.x):
+// 1) Stream meta: uint32 fourcc
+// special values 0 = disabled, 1 = error
+// 2) Then a stream of variable packets. Each packet starts with 4 bytes
+// that disambiguate the type:
+// a) Session meta (12 bytes total): uint32 flags(bit31=1) | uint32 w | uint32 h
+// flags bit0 = client-resize. May recur on rotation.
+// b) Frame header (12 bytes total): uint64 ptsAndFlags | uint32 size
+// ptsAndFlags top bits:
+// bit 63 = SESSION (never set in frame headers)
+// bit 62 = CONFIG (CSD, e.g. SPS/PPS for h264)
+// bit 61 = KEYFRAME
+// rest = pts microseconds
+// followed by `size` bytes of encoded payload.
+//
+// The constructor takes a plain InputStream so this class is
+// android-free and unit-testable. The caller owns stream lifecycle
+// (close it to unblock reads on stop()).
+public final class VideoStream {
+
+ public interface SizeListener { void onSize(int w, int h); }
+
+ private static final long FLAG_SESSION = 1L << 63;
+ private static final long FLAG_CONFIG = 1L << 62;
+ private static final long FLAG_KEYFRAME = 1L << 61;
+ private static final long PTS_MASK = ~(FLAG_SESSION | FLAG_CONFIG | FLAG_KEYFRAME);
+ private static final int FLAG_SESSION_INT_BIT = 0x80000000;
+
+ private static final int MAX_FRAME_SIZE = 8 * 1024 * 1024;
+
+ private final InputStream source;
+ private final VideoFrames sink;
+ private final SizeListener sizeListener;
+ private volatile VideoRecorder recorder; // optional tap
+ private volatile Runnable onEnd; // fired once when run() exits
+ private Thread thread;
+ private volatile boolean stop;
+
+ // Parser state held across packets in run().
+ private int fourcc;
+ private boolean configured;
+ private int curW, curH;
+
+ public VideoStream(InputStream source, VideoFrames sink, SizeListener sizeListener) {
+ this.source = source;
+ this.sink = sink;
+ this.sizeListener = sizeListener;
+ }
+
+ public void start() {
+ thread = new Thread(this::run, "video-reader");
+ thread.start();
+ }
+
+ public void stop() {
+ stop = true;
+ if (thread != null) thread.interrupt();
+ }
+
+ public void setRecorder(VideoRecorder r) {
+ this.recorder = r;
+ }
+
+ // Fired exactly once, on the reader thread, when run() exits - whether
+ // by EOF, error, or stop(). The video socket is the authoritative
+ // stream: when it ends mid-session the link is gone, so Session uses
+ // this to surface a disconnect to the UI instead of freezing.
+ public void setOnEnd(Runnable r) {
+ this.onEnd = r;
+ }
+
+ // Visible for tests: parse the same way the thread does, on the caller's thread.
+ public void run() {
+ try {
+ byte[] four = new byte[4];
+ Wire.readFully(source, four);
+ fourcc = Wire.readBe32(four, 0);
+ if (fourcc == 0) throw new IOException("video: server reports stream disabled");
+ if (fourcc == 1) throw new IOException("video: server reports configuration error");
+ Log.i("video meta codec=%s", Wire.fourccName(fourcc));
+
+ byte[] tail8 = new byte[8];
+ long frames = 0;
+
+ while (!stop) {
+ Wire.readFully(source, four);
+ int hi = Wire.readBe32(four, 0);
+
+ if ((hi & FLAG_SESSION_INT_BIT) != 0) {
+ parseSessionMeta(hi, tail8);
+ } else {
+ parseFrame(hi, tail8);
+ if (++frames == 1) Log.i("video frame n=1");
+ }
+ }
+ } catch (IOException e) {
+ if (!stop) Log.e(e, "video reader");
+ } catch (Exception e) {
+ Log.e(e, "video reader unexpected");
+ } finally {
+ Log.i("video reader: end");
+ Runnable r = onEnd;
+ if (r != null) r.run();
+ }
+ }
+
+ private void parseSessionMeta(int hi, byte[] tail8) throws IOException {
+ Wire.readFully(source, tail8);
+ int newW = Wire.readBe32(tail8, 0);
+ int newH = Wire.readBe32(tail8, 4);
+ boolean clientResize = (hi & 1) != 0;
+ Log.i("video session meta %dx%d client_resize=%s", newW, newH, clientResize);
+
+ if (!configured) {
+ curW = newW; curH = newH;
+ sink.configure(fourcc, curW, curH);
+ configured = true;
+ } else if (newW != curW || newH != curH) {
+ Log.i("video resize %dx%d -> %dx%d", curW, curH, newW, newH);
+ curW = newW; curH = newH;
+ sink.reconfigure(fourcc, curW, curH);
+ }
+ if (sizeListener != null) sizeListener.onSize(curW, curH);
+ VideoRecorder r = recorder;
+ if (r != null) r.onMeta(fourcc, curW, curH);
+ }
+
+ private void parseFrame(int hi, byte[] tail8) throws IOException {
+ Wire.readFully(source, tail8);
+ long pts = ((long) hi << 32) | (Wire.readBe32(tail8, 0) & 0xffffffffL);
+ int size = Wire.readBe32(tail8, 4);
+ boolean cfg = (pts & FLAG_CONFIG) != 0;
+ boolean key = (pts & FLAG_KEYFRAME) != 0;
+ long ptsUs = pts & PTS_MASK;
+
+ if (size <= 0 || size > MAX_FRAME_SIZE) {
+ throw new IOException("video frame size out of range: " + size);
+ }
+ // Validate ordering BEFORE allocating the payload buffer - a
+ // malformed early frame could otherwise OOM on small devices.
+ if (!configured) throw new IOException("video frame before session meta");
+
+ byte[] payload = new byte[size];
+ Wire.readFully(source, payload);
+ sink.feed(payload, ptsUs, cfg);
+ VideoRecorder r = recorder;
+ if (r != null) r.onFrame(payload, ptsUs, cfg, key);
+ }
+}
diff --git a/app/src/main/java/invalid/lena/scrcpy/Wire.java b/app/src/main/java/invalid/lena/scrcpy/Wire.java
new file mode 100644
index 0000000..7ed6c6a
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Wire.java
@@ -0,0 +1,99 @@
+package invalid.lena.scrcpy;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+
+// Byte-order helpers used everywhere we touch raw streams.
+//
+// scrcpy server framing is big-endian (java standard).
+// adb sync framing is little-endian (legacy).
+//
+// FOURCCs are read as a big-endian uint32, which matches the literal
+// 4-char ASCII tag, so '"h264"' becomes 0x68323634.
+public final class Wire {
+
+ private Wire() {}
+
+ // ---- big-endian (scrcpy) ----
+
+ public static int readBe32(byte[] b, int off) {
+ return ((b[off] & 0xff) << 24)
+ | ((b[off + 1] & 0xff) << 16)
+ | ((b[off + 2] & 0xff) << 8)
+ | (b[off + 3] & 0xff);
+ }
+
+ public static long readBe64(byte[] b, int off) {
+ return ((long)(readBe32(b, off)) << 32) | (readBe32(b, off + 4) & 0xffffffffL);
+ }
+
+ public static void writeBe32(byte[] b, int off, int v) {
+ b[off] = (byte)(v >>> 24);
+ b[off + 1] = (byte)(v >>> 16);
+ b[off + 2] = (byte)(v >>> 8);
+ b[off + 3] = (byte) v;
+ }
+
+ public static void writeBe64(byte[] b, int off, long v) {
+ writeBe32(b, off, (int)(v >>> 32));
+ writeBe32(b, off + 4, (int) v);
+ }
+
+ // ---- little-endian (adb sync) ----
+
+ public static int readLe32(byte[] b, int off) {
+ return (b[off] & 0xff)
+ | ((b[off + 1] & 0xff) << 8)
+ | ((b[off + 2] & 0xff) << 16)
+ | ((b[off + 3] & 0xff) << 24);
+ }
+
+ public static void writeLe32(byte[] b, int off, int v) {
+ b[off] = (byte) v;
+ b[off + 1] = (byte)(v >>> 8);
+ b[off + 2] = (byte)(v >>> 16);
+ b[off + 3] = (byte)(v >>> 24);
+ }
+
+ // ---- I/O helpers ----
+
+ public static void readFully(InputStream in, byte[] buf, int off, int len) throws IOException {
+ int got = 0;
+ while (got < len) {
+ int n = in.read(buf, off + got, len - got);
+ if (n < 0) throw new EOFException(
+ "short read: wanted " + len + " got " + got);
+ got += n;
+ }
+ }
+
+ public static void readFully(InputStream in, byte[] buf) throws IOException {
+ readFully(in, buf, 0, buf.length);
+ }
+
+ // scrcpy codec identifiers, mirrored from com.genymobile.scrcpy.{video,audio}.*Codec.
+ // Short names (raw, aac) are NUL-padded on the left, not space-padded on the right.
+ // Declared as int-literal constants so they can drive switch-case labels.
+ public static final int CODEC_H264 = 0x68_32_36_34; // 'h264'
+ public static final int CODEC_H265 = 0x68_32_36_35; // 'h265'
+ public static final int CODEC_AV1 = 0x61_76_30_31; // 'av01'
+ public static final int CODEC_OPUS = 0x6f_70_75_73; // 'opus'
+ public static final int CODEC_FLAC = 0x66_6c_61_63; // 'flac'
+ public static final int CODEC_AAC = 0x00_61_61_63; // '\0aac'
+ public static final int CODEC_RAW = 0x00_72_61_77; // '\0raw'
+
+ public static String fourccName(int v) {
+ // Skip any leading NUL bytes - scrcpy left-pads short names like
+ // 'raw' and 'aac' with \0, which would otherwise render as
+ // non-printable characters in logs.
+ char[] cs = new char[]{
+ (char)((v >>> 24) & 0xff),
+ (char)((v >>> 16) & 0xff),
+ (char)((v >>> 8) & 0xff),
+ (char)( v & 0xff)};
+ int from = 0;
+ while (from < cs.length && cs[from] == 0) from++;
+ return new String(cs, from, cs.length - from);
+ }
+}
diff --git a/app/src/main/res/drawable/bg_card.xml b/app/src/main/res/drawable/bg_card.xml
new file mode 100644
index 0000000..17a0cfa
--- /dev/null
+++ b/app/src/main/res/drawable/bg_card.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Rounded surface panel used to group related controls. -->
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+ android:shape="rectangle">
+ <solid android:color="@color/surface"/>
+ <stroke android:width="1dp" android:color="@color/stroke"/>
+ <corners android:radius="@dimen/card_radius"/>
+</shape>
diff --git a/app/src/main/res/drawable/bg_field.xml b/app/src/main/res/drawable/bg_field.xml
new file mode 100644
index 0000000..e656ad4
--- /dev/null
+++ b/app/src/main/res/drawable/bg_field.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Text-field box. Accent border when focused, neutral otherwise. -->
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+ <item android:state_focused="true">
+ <shape android:shape="rectangle">
+ <solid android:color="@color/surface_alt"/>
+ <stroke android:width="2dp" android:color="@color/accent"/>
+ <corners android:radius="@dimen/field_radius"/>
+ </shape>
+ </item>
+ <item>
+ <shape android:shape="rectangle">
+ <solid android:color="@color/surface_alt"/>
+ <stroke android:width="1dp" android:color="@color/stroke"/>
+ <corners android:radius="@dimen/field_radius"/>
+ </shape>
+ </item>
+</selector>
diff --git a/app/src/main/res/drawable/bg_status_pill.xml b/app/src/main/res/drawable/bg_status_pill.xml
new file mode 100644
index 0000000..ae91094
--- /dev/null
+++ b/app/src/main/res/drawable/bg_status_pill.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Floating scrim pill behind the Mirror status text and buttons. -->
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+ android:shape="rectangle">
+ <solid android:color="@color/status_scrim"/>
+ <corners android:radius="@dimen/button_radius"/>
+</shape>
diff --git a/app/src/main/res/drawable/btn_primary.xml b/app/src/main/res/drawable/btn_primary.xml
new file mode 100644
index 0000000..75aab01
--- /dev/null
+++ b/app/src/main/res/drawable/btn_primary.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Filled accent button with the platform press ripple on top. -->
+<ripple xmlns:android="http://schemas.android.com/apk/res/android"
+ android:color="?android:attr/colorControlHighlight">
+ <item>
+ <shape android:shape="rectangle">
+ <solid android:color="@color/accent"/>
+ <corners android:radius="@dimen/button_radius"/>
+ </shape>
+ </item>
+</ripple>
diff --git a/app/src/main/res/drawable/btn_secondary.xml b/app/src/main/res/drawable/btn_secondary.xml
new file mode 100644
index 0000000..2c3a618
--- /dev/null
+++ b/app/src/main/res/drawable/btn_secondary.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Outlined accent button (transparent fill, accent border). -->
+<ripple xmlns:android="http://schemas.android.com/apk/res/android"
+ android:color="?android:attr/colorControlHighlight">
+ <item>
+ <shape android:shape="rectangle">
+ <solid android:color="@android:color/transparent"/>
+ <stroke android:width="1dp" android:color="@color/accent"/>
+ <corners android:radius="@dimen/button_radius"/>
+ </shape>
+ </item>
+</ripple>
diff --git a/app/src/main/res/drawable/ic_device.xml b/app/src/main/res/drawable/ic_device.xml
new file mode 100644
index 0000000..f12a226
--- /dev/null
+++ b/app/src/main/res/drawable/ic_device.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Small phone outline used as the leading glyph on saved-device rows. -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:strokeColor="@color/accent"
+ android:strokeWidth="2"
+ android:strokeLineJoin="round"
+ android:fillColor="#00000000"
+ android:pathData="M8,3 H16 A2,2 0 0 1 18,5 V19 A2,2 0 0 1 16,21 H8 A2,2 0 0 1 6,19 V5 A2,2 0 0 1 8,3 Z"/>
+</vector>
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..e26af1f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Dark CRT-terminal blue. Plain solid colour so the adaptive icon
+ reads cleanly under any launcher crop (circle, squircle, square). -->
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+ android:shape="rectangle">
+ <solid android:color="#0E1E28"/>
+</shape>
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..e5b7400
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Foreground: a fresh take on wireless screen mirroring.
+ 1. A small phone (source) and a larger phone (target display).
+ 2. Cyan wireless arcs rising from the small phone toward the large
+ one - the wireless ADB link / casting direction.
+ 3. A play triangle on the large screen - references scrcpy's
+ upstream identity ("playing what the device is showing").
+ Geometry mirrors the AI-generated reference 1:1. The arcs carry the
+ one accent colour; everything else is the light foreground tone.
+ For the Android 13+ themed (monochrome) icon the same drawable is
+ reused: the system tints by coverage, so the colours here are
+ irrelevant in that mode. Adaptive viewport is 108x108; all content
+ stays inside the inner safe zone. -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="108dp"
+ android:height="108dp"
+ android:viewportWidth="108"
+ android:viewportHeight="108">
+
+ <!-- Phone outlines (source 20x34, target 28x50), stroke only. -->
+ <path
+ android:strokeColor="#E8E8E8"
+ android:strokeWidth="3"
+ android:strokeLineJoin="round"
+ android:fillColor="#00000000"
+ android:pathData="M57,29 H75 A5,5 0 0 1 80,34 V74 A5,5 0 0 1 75,79 H57 A5,5 0 0 1 52,74 V34 A5,5 0 0 1 57,29 Z
+ M30,47 H42 A4,4 0 0 1 46,51 V77 A4,4 0 0 1 42,81 H30 A4,4 0 0 1 26,77 V51 A4,4 0 0 1 30,47 Z"/>
+
+ <!-- Speaker slits and home indicators on both phones. -->
+ <path
+ android:strokeColor="#E8E8E8"
+ android:strokeWidth="2"
+ android:strokeLineCap="round"
+ android:fillColor="#00000000"
+ android:pathData="M62,35 H70 M61,74 H71 M32,52 H40 M32,76 H40"/>
+
+ <!-- Play triangle, filled, inside the large phone screen. -->
+ <path
+ android:fillColor="#E8E8E8"
+ android:pathData="M61,46 L61,62 L74,54 Z"/>
+
+ <!-- Wireless arcs: three concentric quarter-arcs centred at (40,48),
+ sweeping from north to east so they fan up-right from the small
+ phone toward the large one. -->
+ <path
+ android:strokeColor="#3FD0D6"
+ android:strokeWidth="2.5"
+ android:strokeLineCap="round"
+ android:fillColor="#00000000"
+ android:pathData="M40,43 A5,5 0 0 1 45,48 M40,39.5 A8.5,8.5 0 0 1 48.5,48 M40,36 A12,12 0 0 1 52,48"/>
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml
new file mode 100644
index 0000000..f592b9d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Status-bar notification icon: white-on-transparent silhouette (the
+ launcher mipmap is the wrong shape and colour for a small icon). The
+ system tints it. Phone outline with a play triangle, matching the
+ app's visual identity. -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24"
+ android:tint="#FFFFFF">
+ <path
+ android:strokeColor="#FFFFFF"
+ android:strokeWidth="1.8"
+ android:strokeLineJoin="round"
+ android:fillColor="#00000000"
+ android:pathData="M7,2 H17 A2,2 0 0 1 19,4 V20 A2,2 0 0 1 17,22 H7 A2,2 0 0 1 5,20 V4 A2,2 0 0 1 7,2 Z"/>
+ <path
+ android:fillColor="#FFFFFF"
+ android:pathData="M10,8 L10,16 L16,12 Z"/>
+</vector>
diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml
new file mode 100644
index 0000000..696f6c2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_settings.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Gear glyph for the Settings action in the Main header. -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24"
+ android:tint="@color/on_surface_muted">
+ <path
+ android:fillColor="#FFFFFF"
+ android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94l-0.36,-2.54c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41l-0.36,2.54c-0.59,0.24 -1.13,0.57 -1.62,0.94l-2.39,-0.96c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.82,11.69 4.82,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
+</vector>
diff --git a/app/src/main/res/layout/device_row.xml b/app/src/main/res/layout/device_row.xml
new file mode 100644
index 0000000..f06ab20
--- /dev/null
+++ b/app/src/main/res/layout/device_row.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Saved-device row: phone glyph + "host:port" label, with the platform
+ touch ripple. Bound by an ArrayAdapter via R.id.device_label. -->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:minHeight="56dp"
+ android:paddingHorizontal="@dimen/space_sm"
+ android:paddingVertical="@dimen/space_md"
+ android:background="?android:attr/selectableItemBackground">
+
+ <ImageView
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:src="@drawable/ic_device"
+ android:contentDescription="@null"/>
+
+ <TextView
+ android:id="@+id/device_label"
+ style="@style/DeviceLabel"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:layout_marginStart="@dimen/space_md"/>
+
+</LinearLayout>
diff --git a/app/src/main/res/layout/main.xml b/app/src/main/res/layout/main.xml
new file mode 100644
index 0000000..acd4a3c
--- /dev/null
+++ b/app/src/main/res/layout/main.xml
@@ -0,0 +1,181 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:padding="@dimen/space_lg">
+
+ <!-- Header: app glyph + name, Settings on the right. -->
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginBottom="@dimen/space_lg">
+
+ <ImageView
+ android:layout_width="40dp"
+ android:layout_height="40dp"
+ android:src="@drawable/ic_launcher_foreground"
+ android:scaleType="fitCenter"
+ android:contentDescription="@null"/>
+
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:layout_marginStart="@dimen/space_sm"
+ android:text="@string/app_name"
+ android:textColor="@color/on_surface"
+ android:textSize="22sp"
+ android:textStyle="bold"/>
+
+ <ImageButton
+ android:id="@+id/settings"
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:src="@drawable/ic_settings"
+ android:scaleType="centerInside"
+ android:background="?android:attr/selectableItemBackgroundBorderless"
+ android:contentDescription="@string/settings"/>
+
+ </LinearLayout>
+
+ <!-- Pairing form. Fields are grouped and labelled by where their
+ value comes from on the target's Wireless debugging screen. -->
+ <LinearLayout
+ style="@style/Card"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <TextView
+ style="@style/SectionHeader"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/pair_a_device"/>
+
+ <TextView
+ style="@style/Caption"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/pair_help"/>
+
+ <TextView
+ style="@style/FieldLabel"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/label_host"/>
+
+ <EditText
+ android:id="@+id/host"
+ style="@style/Field"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="@string/host_hint"
+ android:inputType="text"
+ android:autofillHints="postalAddress"/>
+
+ <TextView
+ style="@style/FieldLabel"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/label_pairing"/>
+
+ <TextView
+ style="@style/Caption"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/sub_pairing"/>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginTop="@dimen/space_xs">
+
+ <EditText
+ android:id="@+id/pair_port"
+ style="@style/Field"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:layout_marginEnd="@dimen/space_sm"
+ android:hint="@string/pair_port_hint"
+ android:inputType="number"
+ android:autofillHints="off"/>
+
+ <EditText
+ android:id="@+id/pair_code"
+ style="@style/Field"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:hint="@string/pair_code_hint"
+ android:inputType="numberPassword"
+ android:maxLength="6"
+ android:autofillHints="off"/>
+
+ </LinearLayout>
+
+ <TextView
+ style="@style/FieldLabel"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/label_connect"/>
+
+ <TextView
+ style="@style/Caption"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/sub_connect"/>
+
+ <EditText
+ android:id="@+id/connect_port"
+ style="@style/Field"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="@dimen/space_xs"
+ android:hint="@string/connect_port_hint"
+ android:inputType="number"
+ android:autofillHints="off"/>
+
+ <Button
+ android:id="@+id/pair"
+ style="@style/Button.Primary"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="@dimen/space_lg"
+ android:text="@string/pair_and_save"/>
+
+ </LinearLayout>
+
+ <TextView
+ style="@style/SectionHeader"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/saved_devices"/>
+
+ <FrameLayout
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1">
+
+ <ListView
+ android:id="@+id/devices"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:divider="@color/stroke"
+ android:dividerHeight="1dp"/>
+
+ <TextView
+ android:id="@+id/devices_empty"
+ style="@style/Note"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:gravity="center_horizontal"
+ android:paddingTop="@dimen/space_xl"
+ android:text="@string/no_devices_yet"/>
+
+ </FrameLayout>
+
+</LinearLayout>
diff --git a/app/src/main/res/layout/mirror.xml b/app/src/main/res/layout/mirror.xml
new file mode 100644
index 0000000..cf24e11
--- /dev/null
+++ b/app/src/main/res/layout/mirror.xml
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Release Mirror layout: SurfaceView for the video pipeline plus a
+ floating top status pill with Record and a Reconnect button (visible
+ only when the session is in DISCONNECTED state). The debug layout
+ under src/debug/res/layout/mirror.xml shadows this with a TextureView
+ and extra stats overlay used by the e2e screen capture. -->
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="#000000">
+
+ <SurfaceView
+ android:id="@+id/surface"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_gravity="center"/>
+
+ <LinearLayout
+ android:id="@+id/status_bar"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="top"
+ android:layout_margin="@dimen/space_sm"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:paddingHorizontal="@dimen/space_md"
+ android:paddingVertical="@dimen/space_sm"
+ android:background="@drawable/bg_status_pill">
+
+ <TextView
+ android:id="@+id/status_text"
+ android:layout_width="0dp"
+ android:layout_weight="1"
+ android:layout_height="wrap_content"
+ android:textColor="#ffffff"
+ android:textSize="14sp"
+ android:fontFamily="monospace"
+ android:singleLine="true"
+ android:ellipsize="end"
+ android:text="@string/connecting"/>
+
+ <Button
+ android:id="@+id/record"
+ style="@style/Button.Secondary"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="@dimen/space_sm"
+ android:minHeight="0dp"
+ android:paddingHorizontal="@dimen/space_md"
+ android:paddingVertical="@dimen/space_xs"
+ android:text="@string/record"/>
+
+ <Button
+ android:id="@+id/reconnect"
+ style="@style/Button.Secondary"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="@dimen/space_sm"
+ android:minHeight="0dp"
+ android:paddingHorizontal="@dimen/space_md"
+ android:paddingVertical="@dimen/space_xs"
+ android:text="@string/reconnect"
+ android:visibility="gone"/>
+
+ </LinearLayout>
+
+</FrameLayout>
diff --git a/app/src/main/res/layout/settings.xml b/app/src/main/res/layout/settings.xml
new file mode 100644
index 0000000..5d4e450
--- /dev/null
+++ b/app/src/main/res/layout/settings.xml
@@ -0,0 +1,294 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="@dimen/space_lg">
+
+ <TextView
+ style="@style/Note"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingBottom="@dimen/space_lg"
+ android:text="@string/settings_apply_on_reconnect"/>
+
+ <LinearLayout
+ style="@style/Card"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <TextView
+ style="@style/SectionHeader"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/video_codec"/>
+
+ <RadioGroup
+ android:id="@+id/video_codec"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <RadioButton
+ android:id="@+id/video_h264"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="H.264 (default)"/>
+
+ <RadioButton
+ android:id="@+id/video_h265"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="H.265"/>
+
+ <RadioButton
+ android:id="@+id/video_av1"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="AV1 (requires target Android 14+)"/>
+
+ </RadioGroup>
+
+ </LinearLayout>
+
+ <LinearLayout
+ style="@style/Card"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <TextView
+ style="@style/SectionHeader"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/audio_codec"/>
+
+ <RadioGroup
+ android:id="@+id/audio_codec"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <RadioButton
+ android:id="@+id/audio_raw"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Raw PCM (1.5 Mbps, default)"/>
+
+ <RadioButton
+ android:id="@+id/audio_opus"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Opus (~128 kbps)"/>
+
+ </RadioGroup>
+
+ </LinearLayout>
+
+ <LinearLayout
+ style="@style/Card"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <TextView
+ style="@style/SectionHeader"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/max_size"/>
+
+ <RadioGroup
+ android:id="@+id/max_size"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <RadioButton
+ android:id="@+id/max_size_0"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Original (default)"/>
+
+ <RadioButton
+ android:id="@+id/max_size_480"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="480"/>
+
+ <RadioButton
+ android:id="@+id/max_size_720"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="720"/>
+
+ <RadioButton
+ android:id="@+id/max_size_1080"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="1080"/>
+
+ <RadioButton
+ android:id="@+id/max_size_1440"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="1440"/>
+
+ <RadioButton
+ android:id="@+id/max_size_2160"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="2160"/>
+
+ </RadioGroup>
+
+ </LinearLayout>
+
+ <LinearLayout
+ style="@style/Card"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <TextView
+ style="@style/SectionHeader"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/video_bit_rate"/>
+
+ <RadioGroup
+ android:id="@+id/video_bit_rate"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <RadioButton
+ android:id="@+id/bit_rate_1m"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="1 Mbps"/>
+
+ <RadioButton
+ android:id="@+id/bit_rate_2m"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="2 Mbps"/>
+
+ <RadioButton
+ android:id="@+id/bit_rate_4m"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="4 Mbps"/>
+
+ <RadioButton
+ android:id="@+id/bit_rate_8m"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="8 Mbps (default)"/>
+
+ <RadioButton
+ android:id="@+id/bit_rate_16m"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="16 Mbps"/>
+
+ </RadioGroup>
+
+ </LinearLayout>
+
+ <LinearLayout
+ style="@style/Card"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <TextView
+ style="@style/SectionHeader"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/max_fps"/>
+
+ <RadioGroup
+ android:id="@+id/max_fps"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <RadioButton
+ android:id="@+id/max_fps_0"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Unlimited (default)"/>
+
+ <RadioButton
+ android:id="@+id/max_fps_30"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="30"/>
+
+ <RadioButton
+ android:id="@+id/max_fps_60"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="60"/>
+
+ <RadioButton
+ android:id="@+id/max_fps_90"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="90"/>
+
+ <RadioButton
+ android:id="@+id/max_fps_120"
+ style="@style/RadioOption"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="120"/>
+
+ </RadioGroup>
+
+ </LinearLayout>
+
+ <LinearLayout
+ style="@style/Card"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="0dp">
+
+ <TextView
+ style="@style/SectionHeader"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/display"/>
+
+ <CheckBox
+ android:id="@+id/show_status_bar"
+ style="@style/RadioOption"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/show_status_bar"/>
+
+ </LinearLayout>
+
+ </LinearLayout>
+
+</ScrollView>
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..93542a7
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@drawable/ic_launcher_background"/>
+ <foreground android:drawable="@drawable/ic_launcher_foreground"/>
+ <monochrome android:drawable="@drawable/ic_launcher_foreground"/>
+</adaptive-icon>
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..7e9fa9b
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Dark palette built around the launcher icon's CRT-blue. The light
+ foreground (#E8E8E8) matches the icon; the cyan/teal accent tints
+ interactive controls. Hex values live here only; layouts reference
+ them by name. -->
+<resources>
+ <color name="bg">#0B1620</color>
+ <color name="surface">#14242E</color>
+ <color name="surface_alt">#0E1E28</color>
+ <color name="stroke">#283D49</color>
+ <color name="accent">#3FD0D6</color>
+ <color name="on_surface">#E8E8E8</color>
+ <color name="on_surface_muted">#8AA0AB</color>
+ <color name="on_accent">#04222B</color>
+ <color name="status_scrim">#B3000000</color>
+</resources>
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..0ec4c88
--- /dev/null
+++ b/app/src/main/res/values/dimens.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Single spacing scale and corner radii, referenced everywhere so the
+ layout rhythm stays consistent. -->
+<resources>
+ <dimen name="space_xs">4dp</dimen>
+ <dimen name="space_sm">8dp</dimen>
+ <dimen name="space_md">12dp</dimen>
+ <dimen name="space_lg">16dp</dimen>
+ <dimen name="space_xl">24dp</dimen>
+
+ <dimen name="card_radius">14dp</dimen>
+ <dimen name="field_radius">8dp</dimen>
+ <dimen name="button_radius">10dp</dimen>
+
+ <dimen name="card_padding">16dp</dimen>
+ <dimen name="touch_min">48dp</dimen>
+</resources>
diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml
new file mode 100644
index 0000000..f551102
--- /dev/null
+++ b/app/src/main/res/values/ids.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- IDs referenced from Mirror.java that live only in the debug
+ layout (src/debug/res/layout/mirror.xml). Declaring them here
+ means R.id.* is stable across both build types; findViewById
+ returns null in release where the views are absent. -->
+<resources>
+ <item name="overlay_target" type="id"/>
+ <item name="overlay_stats" type="id"/>
+ <item name="overlay_event" type="id"/>
+</resources>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..a9a552c
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="app_name">scrcpy</string>
+ <string name="host_hint">192.168.1.42</string>
+ <string name="pair_port_hint">37123</string>
+ <string name="pair_code_hint">6 digits</string>
+ <string name="connect_port_hint">41234</string>
+ <string name="pair_help">On the target: Settings &gt; Developer options &gt; Wireless debugging</string>
+ <string name="label_host">Host / IP address</string>
+ <string name="label_pairing">Pairing port and code</string>
+ <string name="sub_pairing">From the “Pair device with pairing code” dialog</string>
+ <string name="label_connect">Connection port</string>
+ <string name="sub_connect">Shown on the Wireless debugging screen</string>
+ <string name="pair_and_save">Pair and save</string>
+ <string name="pair_a_device">Pair a device</string>
+ <string name="saved_devices">Saved devices</string>
+ <string name="notif_session_active">Mirroring active</string>
+ <string name="settings">Settings</string>
+ <string name="video_codec">Video codec</string>
+ <string name="audio_codec">Audio codec</string>
+ <string name="forget_device">Forget device?</string>
+ <string name="max_size">Max size (long edge, px)</string>
+ <string name="video_bit_rate">Video bit rate</string>
+ <string name="max_fps">Max fps</string>
+ <string name="reconnect">Reconnect</string>
+ <string name="connecting">connecting…</string>
+ <string name="disconnected">disconnected</string>
+ <string name="record">Record</string>
+ <string name="record_on">Stop recording</string>
+ <string name="hint_long_press_back">Long-press Back to send it to the target device.</string>
+ <string name="pairing">Pairing…</string>
+ <string name="settings_apply_on_reconnect">Changes apply on the next connection.</string>
+ <string name="display">Display</string>
+ <string name="show_status_bar">Show status bar (IP, resolution, Record) while mirroring</string>
+ <string name="no_devices_yet">No devices paired yet - fill the fields above and tap Pair.</string>
+</resources>
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..d0b07ac
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Reusable widget styles. Applied per-widget via style="@style/..." so
+ behaviour stays explicit and visible at the call site. -->
+<resources>
+ <style name="Card">
+ <item name="android:orientation">vertical</item>
+ <item name="android:background">@drawable/bg_card</item>
+ <item name="android:padding">@dimen/card_padding</item>
+ <item name="android:layout_marginBottom">@dimen/space_lg</item>
+ </style>
+
+ <style name="SectionHeader">
+ <item name="android:textColor">@color/on_surface</item>
+ <item name="android:textStyle">bold</item>
+ <item name="android:textSize">16sp</item>
+ <item name="android:paddingBottom">@dimen/space_sm</item>
+ </style>
+
+ <style name="Field" parent="@android:style/Widget.Material.EditText">
+ <item name="android:background">@drawable/bg_field</item>
+ <item name="android:textColor">@color/on_surface</item>
+ <item name="android:textColorHint">@color/on_surface_muted</item>
+ <item name="android:paddingHorizontal">@dimen/space_md</item>
+ <item name="android:paddingVertical">@dimen/space_md</item>
+ <item name="android:minHeight">@dimen/touch_min</item>
+ </style>
+
+ <style name="Button.Primary" parent="@android:style/Widget.Material.Button">
+ <item name="android:background">@drawable/btn_primary</item>
+ <item name="android:textColor">@color/on_accent</item>
+ <item name="android:textStyle">bold</item>
+ <item name="android:textAllCaps">false</item>
+ <item name="android:minHeight">@dimen/touch_min</item>
+ </style>
+
+ <style name="Button.Secondary" parent="@android:style/Widget.Material.Button">
+ <item name="android:background">@drawable/btn_secondary</item>
+ <item name="android:textColor">@color/accent</item>
+ <item name="android:textAllCaps">false</item>
+ <item name="android:minWidth">0dp</item>
+ </style>
+
+ <style name="RadioOption">
+ <item name="android:textColor">@color/on_surface</item>
+ <item name="android:textSize">15sp</item>
+ <item name="android:paddingTop">@dimen/space_sm</item>
+ <item name="android:paddingBottom">@dimen/space_sm</item>
+ <item name="android:minHeight">44dp</item>
+ </style>
+
+ <style name="Note">
+ <item name="android:textStyle">italic</item>
+ <item name="android:textColor">@color/on_surface_muted</item>
+ </style>
+
+ <style name="FieldLabel">
+ <item name="android:textColor">@color/on_surface</item>
+ <item name="android:textSize">13sp</item>
+ <item name="android:textStyle">bold</item>
+ <item name="android:paddingTop">@dimen/space_md</item>
+ </style>
+
+ <style name="Caption">
+ <item name="android:textColor">@color/on_surface_muted</item>
+ <item name="android:textSize">12sp</item>
+ </style>
+
+ <style name="DeviceLabel">
+ <item name="android:textColor">@color/on_surface</item>
+ <item name="android:textSize">16sp</item>
+ <item name="android:fontFamily">monospace</item>
+ </style>
+</resources>
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..3d702c6
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Custom theme on the platform's built-in Material theme (ships with
+ Android, not a dependency). Setting the colour attributes makes the
+ framework auto-tint EditText focus, radio buttons, checkboxes, the
+ action bar, and the cursor. NoActionBar / Fullscreen inherit the
+ same colours via dotted-name inheritance. -->
+<resources>
+ <style name="Theme.Scrcpy" parent="@android:style/Theme.Material">
+ <item name="android:colorBackground">@color/bg</item>
+ <item name="android:windowBackground">@color/bg</item>
+ <item name="android:colorPrimary">@color/surface</item>
+ <item name="android:colorPrimaryDark">@color/bg</item>
+ <item name="android:colorAccent">@color/accent</item>
+ <item name="android:textColorPrimary">@color/on_surface</item>
+ <item name="android:textColorSecondary">@color/on_surface_muted</item>
+ <item name="android:textColorHint">@color/on_surface_muted</item>
+ <item name="android:statusBarColor">@color/bg</item>
+ <item name="android:navigationBarColor">@color/bg</item>
+ </style>
+
+ <style name="Theme.Scrcpy.NoActionBar">
+ <item name="android:windowActionBar">false</item>
+ <item name="android:windowNoTitle">true</item>
+ </style>
+
+ <style name="Theme.Scrcpy.Fullscreen">
+ <item name="android:windowActionBar">false</item>
+ <item name="android:windowNoTitle">true</item>
+ <item name="android:windowFullscreen">true</item>
+ <item name="android:windowBackground">@android:color/black</item>
+ </style>
+</resources>
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));
+ }
+}