aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/invalid/lena/scrcpy/Controller.java
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/invalid/lena/scrcpy/Controller.java')
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Controller.java141
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;
}
}
}