aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/invalid/lena
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/invalid/lena')
-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
26 files changed, 3549 insertions, 0 deletions
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);
+ }
+}