From eb0c8951196c637e44daf3c0617b131b997d5d2c Mon Sep 17 00:00:00 2001 From: Lena Date: Thu, 1 Jan 2026 00:00:00 +0000 Subject: scrcpy-android: mirror an Android device over wireless ADB 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. --- .../main/java/invalid/lena/scrcpy/Controller.java | 173 +++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 app/src/main/java/invalid/lena/scrcpy/Controller.java (limited to 'app/src/main/java/invalid/lena/scrcpy/Controller.java') 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 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 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"; + } +} -- cgit v1.2.3