diff options
| author | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
| commit | 852a8a00273c9128efeed0217a8e5d3fcd8bf780 (patch) | |
| tree | c72f959731fa3c7c809cf1a0222363b6c9c9b03b /app/src/main/java/invalid/lena/scrcpy/Controller.java | |
| parent | f379b93d52bf0dbd3816ec3f64d165314771d2a6 (diff) | |
| download | scrcpy-android-852a8a00273c9128efeed0217a8e5d3fcd8bf780.tar.gz | |
app: harden mirroring lifecycle and state
Diffstat (limited to 'app/src/main/java/invalid/lena/scrcpy/Controller.java')
| -rw-r--r-- | app/src/main/java/invalid/lena/scrcpy/Controller.java | 141 |
1 files changed, 95 insertions, 46 deletions
diff --git a/app/src/main/java/invalid/lena/scrcpy/Controller.java b/app/src/main/java/invalid/lena/scrcpy/Controller.java index 242d6a5..3cc321c 100644 --- a/app/src/main/java/invalid/lena/scrcpy/Controller.java +++ b/app/src/main/java/invalid/lena/scrcpy/Controller.java @@ -7,6 +7,7 @@ import android.view.KeyEvent; import android.view.MotionEvent; import java.util.function.Consumer; +import java.util.concurrent.atomic.AtomicReference; // Encodes UI events into scrcpy ControlMessage byte arrays and pushes // them at ControlStream. Also mirrors the remote clipboard locally and @@ -17,22 +18,30 @@ import java.util.function.Consumer; public final class Controller implements ControlStream.InboundSink { private final Consumer<byte[]> sender; + // Null when the user has turned clipboard sync off, which disables + // both directions: nothing is read from this device and nothing the + // target sends is written to it. private final ClipboardManager clipboard; - public volatile String lastEvent = "(idle)"; + private final boolean clipboardSync; // 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; + private final TouchGeometry geometry = new TouchGeometry(); - // Suppress one local clipboard change after we set it from a remote update. - private volatile boolean suppressNextClipChange; + // The latest value applied from the target. Keeping the value, rather + // than a one-shot boolean, cannot consume an unrelated user clipboard + // change when Android delays or omits our own callback. + private final AtomicReference<String> remoteClipboardText = new AtomicReference<>(); public Controller(Context ctx, Consumer<byte[]> sender) { this.sender = sender; - this.clipboard = (ClipboardManager) ctx.getSystemService(Context.CLIPBOARD_SERVICE); + this.clipboardSync = Settings.clipboardSync(ctx); + Context app = ctx.getApplicationContext(); + this.clipboard = clipboardSync + ? (ClipboardManager) app.getSystemService(Context.CLIPBOARD_SERVICE) + : null; if (clipboard != null) { clipboard.addPrimaryClipChangedListener(clipListener); } @@ -45,51 +54,68 @@ public final class Controller implements ControlStream.InboundSink { } } - public void setTargetSize(int w, int h) { - targetW = w; targetH = h; + public void setTargetSize(long version, int w, int h) { + geometry.setTargetSize(version, w, h); Log.i("controller: target %dx%d", w, h); } - public void setViewSize(int w, int h) { - viewW = w; viewH = h; + // The rectangle the video occupies in the activity window, which is + // the coordinate space MotionEvents arrive in. + public void setViewport(long version, int x, int y, int w, int h) { + geometry.setViewport(version, x, y, w, h); } // ---- inbound ---- @Override public void onRemoteClipboard(String text) { + if (!clipboardSync || clipboard == null) { + Log.i("clipboard from target ignored: sync is off"); + return; + } Log.i("clipboard from target: %d chars", text.length()); - if (clipboard == null) return; - suppressNextClipChange = true; + remoteClipboardText.set(text); try { clipboard.setPrimaryClip(ClipData.newPlainText("scrcpy-android", text)); } catch (Exception e) { Log.w("clipboard set local failed: %s", e); - suppressNextClipChange = false; + remoteClipboardText.compareAndSet(text, null); } } 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); + // The scrcpy control protocol carries UTF-8 text, not URI or Intent + // clipboard items. Do not coerce URI items: that invokes an arbitrary + // local ContentProvider and may materialize unbounded content before + // our wire-size check. Explicit text fails closed. + CharSequence cs = data.getItemAt(0).getText(); if (cs == null) return; - sendSetClipboard(cs.toString(), false); - Log.i("clipboard to target: %d chars", cs.length()); + String text = cs.toString(); + if (text.equals(remoteClipboardText.get())) return; + remoteClipboardText.set(null); + if (sendSetClipboard(text, false)) { + Log.i("clipboard to target: %d chars", cs.length()); + } + } + + // Android 10+ denies clipboard reads while an app is not focused. A copy + // made in another app therefore cannot be forwarded by the listener at + // copy time. Mirror calls this after regaining focus so that ordinary + // copy, return-to-mirror is reliable. + public void syncLocalClipboard() { + onLocalClipboardChanged(); } // ---- 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; + TouchGeometry.Snapshot g = geometry.snapshot(); + if (g == null) return; // scrcpy's wire protocol uses ACTION_DOWN/UP/MOVE/CANCEL with a // pointerId per message. The server tracks which pointers are @@ -97,57 +123,59 @@ public final class Controller implements ControlStream.InboundSink { // 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 + // ACTION_CANCEL -> ACTION_UP 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); + sendPointer(ev, 0, MotionEvent.ACTION_DOWN, g); break; case MotionEvent.ACTION_POINTER_DOWN: - sendPointer(ev, idx, MotionEvent.ACTION_DOWN, tw, th, vw, vh); + sendPointer(ev, idx, MotionEvent.ACTION_DOWN, g); break; case MotionEvent.ACTION_UP: - sendPointer(ev, 0, MotionEvent.ACTION_UP, tw, th, vw, vh); + sendPointer(ev, 0, MotionEvent.ACTION_UP, g); break; case MotionEvent.ACTION_POINTER_UP: - sendPointer(ev, idx, MotionEvent.ACTION_UP, tw, th, vw, vh); + sendPointer(ev, idx, MotionEvent.ACTION_UP, g); break; case MotionEvent.ACTION_MOVE: - for (int i = 0; i < n; i++) sendPointer(ev, i, MotionEvent.ACTION_MOVE, tw, th, vw, vh); + for (int i = 0; i < n; i++) sendPointer(ev, i, MotionEvent.ACTION_MOVE, g); break; case MotionEvent.ACTION_CANCEL: - for (int i = 0; i < n; i++) sendPointer(ev, i, MotionEvent.ACTION_CANCEL, tw, th, vw, vh); + // Sent as UP, not CANCEL. The server releases a pointer + // only on ACTION_UP (Controller.injectTouch calls + // pointer.setUp(action == ACTION_UP)), so a forwarded + // CANCEL leaves it down in PointersState for the rest of + // the session and every later touch behaves as an extra + // finger. Cancels are routine on the source device: an + // edge swipe or the notification shade stealing the + // gesture produces one. + for (int i = 0; i < n; i++) sendPointer(ev, i, MotionEvent.ACTION_UP, g); 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) { + TouchGeometry.Snapshot g) { 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 tx = TouchMap.map((int) ev.getX(index), g.x, g.w, g.targetW); + int ty = TouchMap.map((int) ev.getY(index), g.y, g.h, g.targetH); // getPressure is calibrated around 1.0 but may exceed it on some // digitizers; clamp instead of masking so hard presses don't wrap // around to a light touch. int pressure = (action == MotionEvent.ACTION_UP) ? 0 - : Math.min((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 + ")"; + : Math.max(0, Math.min((int)(ev.getPressure(index) * 0xffff), 0xffff)); + sender.accept(ControlMessages.touch(action, pointerId, tx, ty, + g.targetW, g.targetH, pressure, /* actionButton */ 0, /* buttons */ 0)); } public void onKey(KeyEvent ev) { - int tw = targetW, th = targetH; - if (tw == 0 || th == 0) return; + if (geometry.snapshot() == null) 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()); @@ -159,22 +187,43 @@ public final class Controller implements ControlStream.InboundSink { public void onBack() { sender.accept(ControlMessages.backOrScreenOn(ControlMessages.ACTION_DOWN)); sender.accept(ControlMessages.backOrScreenOn(ControlMessages.ACTION_UP)); - lastEvent = "back"; + } + + // Home and Recents go as keycodes, not as gestures. On a + // gesture-navigation source a swipe from the bottom edge is claimed + // by the source's own gesture detector and never reaches this app, + // and unlike the side edges that area cannot be released with + // setSystemGestureExclusionRects - the system always keeps it. So + // the target's navigation is unreachable by forwarding touches, on + // any modern source device, and has to be driven explicitly. + public void onHome() { + sendKeycode(ControlMessages.ACTION_DOWN, ControlMessages.KEYCODE_HOME, 0, 0); + sendKeycode(ControlMessages.ACTION_UP, ControlMessages.KEYCODE_HOME, 0, 0); + } + + public void onRecents() { + sendKeycode(ControlMessages.ACTION_DOWN, ControlMessages.KEYCODE_APP_SWITCH, 0, 0); + sendKeycode(ControlMessages.ACTION_UP, ControlMessages.KEYCODE_APP_SWITCH, 0, 0); + } + + public void resetVideo() { + sender.accept(ControlMessages.resetVideo()); + Log.i("controller: reset video"); } // ---- 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) { + private boolean sendSetClipboard(String text, boolean paste) { try { sender.accept(ControlMessages.setClipboard(/* sequence */ 0L, paste, text)); - lastEvent = "clip " + text.length() + " chars"; + return true; } catch (IllegalArgumentException e) { Log.w("clipboard not sent: %s", e.getMessage()); + return false; } } } |