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; import java.util.concurrent.atomic.AtomicBoolean; // 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 Runnable onFatalError; private final LinkedBlockingDeque outbox = new LinkedBlockingDeque<>(MAX_QUEUED); private final AtomicBoolean fatalReported = new AtomicBoolean(); private volatile InboundSink sink; private Thread writer; private Thread reader; private volatile boolean stop; public ControlStream(InputStream in, OutputStream out) { this(in, out, null); } public ControlStream(InputStream in, OutputStream out, Runnable onFatalError) { this.in = in; this.out = out; this.onFatalError = onFatalError; } // 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(); stopThread(writer); stopThread(reader); } 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)) return; // A queue containing only state transitions is unhealthy, but // silently dropping UP/CANCEL/key events leaves input stuck on the // target. Fail the control stream visibly instead. stop = true; if (writer != null) writer.interrupt(); if (reader != null) reader.interrupt(); try { out.close(); } catch (IOException ignored) {} try { in.close(); } catch (IOException ignored) {} Log.e("control: outbox saturated with non-droppable events"); reportFatal(); } // ---- writer ---- public void runWriter() { if (writer == null) writer = Thread.currentThread(); 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"); reportFatal(); } } finally { Log.i("control writer: end"); } } // ---- reader ---- public void runReader() { if (reader == null) reader = Thread.currentThread(); 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"); reportFatal(); } } catch (Exception e) { Log.e(e, "control reader unexpected"); if (!stop) reportFatal(); } finally { Log.i("control reader: end"); } } private void reportFatal() { if (onFatalError != null && fatalReported.compareAndSet(false, true)) { onFatalError.run(); } } private static void stopThread(Thread t) { if (t == null) return; t.interrupt(); if (t == Thread.currentThread()) return; try { t.join(1_000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }