aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/invalid/lena/scrcpy/Controller.java
diff options
context:
space:
mode:
authorLena <lena@omega>2026-01-01 00:00:00 +0000
committerLena <lena@omega>2026-06-24 22:14:50 +0300
commiteb0c8951196c637e44daf3c0617b131b997d5d2c (patch)
tree2f83b6a41cee745467e53fc401942b82cea286ea /app/src/main/java/invalid/lena/scrcpy/Controller.java
downloadscrcpy-android-eb0c8951196c637e44daf3c0617b131b997d5d2c.tar.gz
scrcpy-android: mirror an Android device over wireless ADB0.1
Native Java app for Android 12+ that mirrors another Android device over wireless ADB, forwarding video, audio, touch input, and clipboard. Bundles a pinned scrcpy-server.jar and the vendored libadb-android stack. Supports h264/h265/av1 video and raw/opus audio with in-app codec selection. No NDK, no Kotlin. Includes JVM unit tests and a Docker-based emulator e2e rig.
Diffstat (limited to 'app/src/main/java/invalid/lena/scrcpy/Controller.java')
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Controller.java173
1 files changed, 173 insertions, 0 deletions
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";
+ }
+}