diff options
| author | Lena <lena@omega> | 2026-01-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-06-24 22:14:50 +0300 |
| commit | eb0c8951196c637e44daf3c0617b131b997d5d2c (patch) | |
| tree | 2f83b6a41cee745467e53fc401942b82cea286ea /app/src/main/java/invalid/lena | |
| download | scrcpy-android-eb0c8951196c637e44daf3c0617b131b997d5d2c.tar.gz | |
scrcpy-android: mirror an Android device over wireless ADB0.1
Native Java app for Android 12+ that mirrors another Android
device over wireless ADB, forwarding video, audio, touch input,
and clipboard. Bundles a pinned scrcpy-server.jar and the
vendored libadb-android stack. Supports h264/h265/av1 video and
raw/opus audio with in-app codec selection. No NDK, no Kotlin.
Includes JVM unit tests and a Docker-based emulator e2e rig.
Diffstat (limited to 'app/src/main/java/invalid/lena')
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); + } +} |