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/Controller.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/Controller.java')
| -rw-r--r-- | app/src/main/java/invalid/lena/scrcpy/Controller.java | 173 |
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"; + } +} |