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/scrcpy/ControlStream.java | |
| 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/scrcpy/ControlStream.java')
| -rw-r--r-- | app/src/main/java/invalid/lena/scrcpy/ControlStream.java | 154 |
1 files changed, 154 insertions, 0 deletions
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"); + } + } +} |