Compare commits

..

18 Commits
tmp ... looper

Author SHA1 Message Date
Romain Vimont
8a02e3c2f5 Simplify ClipboardManager wrapper
Use the public ClipboardManager API, with the FakeContext as context.

This requires a running main looper, otherwise clipboard changes are not
processed.

Refs #6009 <https://github.com/Genymobile/scrcpy/pull/6009>
PR #6129 <https://github.com/Genymobile/scrcpy/pull/6129>

Suggested by: Simon Chan <1330321+yume-chan@users.noreply.github.com>
2025-06-11 17:47:37 +02:00
Romain Vimont
283326b2f6 Run a main looper
Instead of blocking the main thread until completion, run a looper.

This will allow the main thread to process any event posted to the main
looper.

Refs #6009 comment <https://github.com/Genymobile/scrcpy/pull/6009#issuecomment-2940810736>
PR #6129 <https://github.com/Genymobile/scrcpy/pull/6129>
2025-06-11 17:47:07 +02:00
Simon Chan
ca4f50c5ef Associate UHID devices to virtual displays
This allows the mouse pointer to appear on the correct display (only for
devices running Android 15+).

Fixes #5547 <https://github.com/Genymobile/scrcpy/issues/5547>
PR #6009 <https://github.com/Genymobile/scrcpy/pull/6009>

Signed-off-by: Romain Vimont <rom@rom1v.com>
2025-06-05 20:34:11 +02:00
Romain Vimont
7a3fe830d4 Synchronize access to DisplayManager
The DisplayManager and its method getDisplayInfo() may be used from both
the Controller thread and the video (main) thread.

PR #6009 <https://github.com/Genymobile/scrcpy/pull/6009>
2025-06-05 20:34:11 +02:00
Romain Vimont
ee414231ed Cache getDisplayInfo method
Do not use reflection to retrieve the method for every call.

PR #6009 <https://github.com/Genymobile/scrcpy/pull/6009>
2025-06-05 20:34:11 +02:00
Simon Chan
41ed40f5f9 Simplify InputManager wrapper
Use the public InputManager API.

PR #6009 <https://github.com/Genymobile/scrcpy/pull/6009>

Signed-off-by: Romain Vimont <rom@rom1v.com>
2025-06-05 20:34:11 +02:00
Colin Kinloch
d2cc930975 Add app name SDL hint
This allows pulseaudio to label the audio stream "scrcpy" rather than
"SDL Application".

PR #6107 <https://github.com/Genymobile/scrcpy/pull/6107>

Signed-off-by: Romain Vimont <rom@rom1v.com>
2025-06-05 19:59:59 +02:00
Romain Vimont
52f5d08d1f Avoid calling wait(0)
Calling wait(0) results in waiting without a timeout, which is
unintended.

Refs #6009 comment <https://github.com/Genymobile/scrcpy/pull/6009#issuecomment-2935930294>
2025-06-03 21:15:11 +02:00
Romain Vimont
70bfa2cf39 Remove useless flag in zsh completion script
The -N flag is only useful after a pattern section (-p) to switch back
to listing command names.

Refs <https://zsh.sourceforge.io/Doc/Release/Completion-System.html>
2025-05-22 20:00:58 +02:00
hltdev8642
38f779d9d3 Escape parentheses in zsh completion script
PR #6079 <https://github.com/Genymobile/scrcpy/pull/6079>

Co-authored-by: Romain Vimont <rom@rom1v.com>
Signed-off-by: Romain Vimont <rom@rom1v.com>
2025-05-22 20:00:53 +02:00
Romain Vimont
8cd63cb63e Report specific error for INJECT_EVENT permission
Some devices require a specific option to be enabled in Developer
Options to avoid a permission issue when injecting input events.

When this error occurs, hide the stack trace and print a human-readable
message explaining how to fix the issue.

PR #6080 <https://github.com/Genymobile/scrcpy/pull/6080>
2025-05-15 19:52:52 +02:00
Romain Vimont
cc309a2b34 Build static linux binary on Ubuntu 22.04
Ubuntu 20.04 is no longer available on GitHub Actions.

Refs <https://github.com/actions/runner-images/issues/11101>
Refs #6050 <https://github.com/Genymobile/scrcpy/pull/6050>

This reverts commit 69858c6f43.
2025-05-02 11:39:47 +02:00
Romain Vimont
91a4a74641 Move regex pattern initialization
If text == null, then the Pattern is not used.
2025-04-25 10:24:07 +02:00
Romain Vimont
48f38c4bb6 Fix default locked capture orientation
The default landscape locked orientation was reversed.

Fixes #6010 <https://github.com/Genymobile/scrcpy/issues/6010>
2025-04-24 16:12:28 +02:00
Romain Vimont
6875e9aa88 Revert "Fix AudioRecord package name for Android 16"
This reverts commit c27d116a66.

This commit breaks audio on Android 16 beta 4.

Refs #5960 comment <https://github.com/Genymobile/scrcpy/issues/5960#issuecomment-2816608015>
Fixes #6021 <https://github.com/Genymobile/scrcpy/issues/6021>
2025-04-24 16:05:13 +02:00
Romain Vimont
1a0d300786 Add missing --screen-off-timeout doc in manpage
Refs eff5b4b219
2025-04-14 18:07:37 +02:00
Romain Vimont
d2447b5c19 Fix --screen-off-timeout bash completion
Only the option must be auto-completed, not its value.
2025-04-14 18:05:08 +02:00
Romain Vimont
882003f314 Fix segfault on SDL event without window
Since #5804, controls have been enabled even with --no-window. As a
result, the Android clipboard is synchronized with the computer, causing
SDL to trigger an SDL_CLIPBOARDUPDATE event.

This event is ignored by scrcpy, but it was still transmitted to the
sc_screen instance, even if it had not been initialized.

Fix the issue by calling sc_screen_handle_event() only when a screen
instance exists.

Refs #5804 <https://github.com/Genymobile/scrcpy/pull/5804>
Fixes #5970 <https://github.com/Genymobile/scrcpy/issues/5970>
2025-04-03 08:15:55 +02:00
18 changed files with 286 additions and 335 deletions

View File

@@ -84,7 +84,7 @@ jobs:
run: release/test_client.sh
build-linux-x86_64:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- name: Check architecture
run: |

View File

@@ -58,7 +58,7 @@ Make sure you [enabled USB debugging][enable-adb] on your device(s).
On some devices (especially Xiaomi), you might get the following error:
```
java.lang.SecurityException: Injecting input events requires the caller (or the source of the instrumentation, if any) to have the INJECT_EVENTS permission.
Injecting input events requires the caller (or the source of the instrumentation, if any) to have the INJECT_EVENTS permission.
```
In that case, you need to enable [an additional option][control] `USB debugging

View File

@@ -205,6 +205,7 @@ _scrcpy() {
|-p|--port \
|--push-target \
|--rotation \
|--screen-off-timeout \
|--tunnel-host \
|--tunnel-port \
|--v4l2-buffer \

View File

@@ -1,4 +1,4 @@
#compdef -N scrcpy -N scrcpy.exe
#compdef scrcpy scrcpy.exe
#
# name: scrcpy
# auth: hltdev [hltdev8642@gmail.com]
@@ -11,7 +11,7 @@ arguments=(
'--always-on-top[Make scrcpy window always on top \(above other windows\)]'
'--angle=[Rotate the video content by a custom angle, in degrees]'
'--audio-bit-rate=[Encode the audio at the given bit-rate]'
'--audio-buffer=[Configure the audio buffering delay (in milliseconds)]'
'--audio-buffer=[Configure the audio buffering delay \(in milliseconds\)]'
'--audio-codec=[Select the audio codec]:codec:(opus aac flac raw)'
'--audio-codec-options=[Set a list of comma-separated key\:type=value options for the device audio encoder]'
'--audio-dup=[Duplicate audio]'
@@ -35,10 +35,10 @@ arguments=(
{-e,--select-tcpip}'[Use TCP/IP device]'
{-f,--fullscreen}'[Start in fullscreen]'
'--force-adb-forward[Do not attempt to use \"adb reverse\" to connect to the device]'
'-G[Use UHID/AOA gamepad (same as --gamepad=uhid or --gamepad=aoa, depending on OTG mode)]'
'-G[Use UHID/AOA gamepad \(same as --gamepad=uhid or --gamepad=aoa, depending on OTG mode\)]'
'--gamepad=[Set the gamepad input mode]:mode:(disabled uhid aoa)'
{-h,--help}'[Print the help]'
'-K[Use UHID/AOA keyboard (same as --keyboard=uhid or --keyboard=aoa, depending on OTG mode)]'
'-K[Use UHID/AOA keyboard \(same as --keyboard=uhid or --keyboard=aoa, depending on OTG mode\)]'
'--keyboard=[Set the keyboard input mode]:mode:(disabled sdk uhid aoa)'
'--kill-adb-on-close[Kill adb when scrcpy terminates]'
'--legacy-paste[Inject computer clipboard text as a sequence of key events on Ctrl+v]'
@@ -48,7 +48,7 @@ arguments=(
'--list-displays[List displays available on the device]'
'--list-encoders[List video and audio encoders available on the device]'
{-m,--max-size=}'[Limit both the width and height of the video to value]'
'-M[Use UHID/AOA mouse (same as --mouse=uhid or --mouse=aoa, depending on OTG mode)]'
'-M[Use UHID/AOA mouse \(same as --mouse=uhid or --mouse=aoa, depending on OTG mode\)]'
'--max-fps=[Limit the frame rate of screen capture]'
'--mouse=[Set the mouse input mode]:mode:(disabled sdk uhid aoa)'
'--mouse-bind=[Configure bindings of secondary clicks]'

View File

@@ -510,6 +510,10 @@ The device serial number. Mandatory only if several devices are connected to adb
.B \-S, \-\-turn\-screen\-off
Turn the device screen off immediately.
.TP
.B "\-\-screen\-off\-timeout " seconds
Set the screen off timeout while scrcpy is running (restore the initial value on exit).
.TP
.BI "\-\-shortcut\-mod " key\fR[+...]][,...]
Specify the modifiers to use for scrcpy shortcuts. Possible keys are "lctrl", "rctrl", "lalt", "ralt", "lsuper" and "rsuper".

View File

@@ -75,6 +75,14 @@
# define SCRCPY_SDL_HAS_THREAD_PRIORITY_TIME_CRITICAL
#endif
#if SDL_VERSION_ATLEAST(2, 0, 18)
# define SCRCPY_SDL_HAS_HINT_APP_NAME
#endif
#if SDL_VERSION_ATLEAST(2, 0, 14)
# define SCRCPY_SDL_HAS_HINT_AUDIO_DEVICE_APP_NAME
#endif
#ifndef HAVE_STRDUP
char *strdup(const char *s);
#endif

View File

@@ -107,6 +107,17 @@ sdl_set_hints(const char *render_driver) {
LOGW("Could not set render driver");
}
// App name used in various contexts (such as PulseAudio)
#if defined(SCRCPY_SDL_HAS_HINT_APP_NAME)
if (!SDL_SetHint(SDL_HINT_APP_NAME, "scrcpy")) {
LOGW("Could not set app name");
}
#elif defined(SCRCPY_SDL_HAS_HINT_AUDIO_DEVICE_APP_NAME)
if (!SDL_SetHint(SDL_HINT_AUDIO_DEVICE_APP_NAME, "scrcpy")) {
LOGW("Could not set audio device app name");
}
#endif
// Linear filtering
if (!SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "1")) {
LOGW("Could not enable linear filtering");
@@ -165,7 +176,7 @@ sdl_configure(bool video_playback, bool disable_screensaver) {
}
static enum scrcpy_exit_code
event_loop(struct scrcpy *s) {
event_loop(struct scrcpy *s, bool has_screen) {
SDL_Event event;
while (SDL_WaitEvent(&event)) {
switch (event.type) {
@@ -197,7 +208,7 @@ event_loop(struct scrcpy *s) {
break;
}
default:
if (!sc_screen_handle_event(&s->screen, &event)) {
if (has_screen && !sc_screen_handle_event(&s->screen, &event)) {
return SCRCPY_EXIT_FAILURE;
}
break;
@@ -933,7 +944,7 @@ aoa_complete:
}
}
ret = event_loop(s);
ret = event_loop(s, options->window);
terminate_event_loop();
LOGD("quit...");

View File

@@ -2,8 +2,10 @@ package com.genymobile.scrcpy;
import com.genymobile.scrcpy.wrappers.ServiceManager;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.AttributionSource;
import android.content.ClipboardManager;
import android.content.ContentResolver;
import android.content.Context;
import android.content.ContextWrapper;
@@ -11,6 +13,8 @@ import android.content.IContentProvider;
import android.os.Binder;
import android.os.Process;
import java.lang.reflect.Field;
public final class FakeContext extends ContextWrapper {
public static final String PACKAGE_NAME = "com.android.shell";
@@ -72,7 +76,7 @@ public final class FakeContext extends ContextWrapper {
@Override
public AttributionSource getAttributionSource() {
AttributionSource.Builder builder = new AttributionSource.Builder(Process.SHELL_UID);
builder.setPackageName("shell");
builder.setPackageName(PACKAGE_NAME);
return builder.build();
}
@@ -91,4 +95,25 @@ public final class FakeContext extends ContextWrapper {
public ContentResolver getContentResolver() {
return contentResolver;
}
@SuppressLint("SoonBlockedPrivateApi")
@Override
public Object getSystemService(String name) {
Object service = super.getSystemService(name);
if (service == null) {
return null;
}
if (Context.CLIPBOARD_SERVICE.equals(name)) {
try {
Field field = ClipboardManager.class.getDeclaredField("mContext");
field.setAccessible(true);
field.set(service, this);
} catch (ReflectiveOperationException e) {
throw new RuntimeException(e);
}
}
return service;
}
}

View File

@@ -25,9 +25,11 @@ import com.genymobile.scrcpy.video.SurfaceEncoder;
import com.genymobile.scrcpy.video.VideoSource;
import android.os.Build;
import android.os.Looper;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
@@ -55,17 +57,7 @@ public final class Server {
this.fatalError = true;
}
if (running == 0 || this.fatalError) {
notify();
}
}
synchronized void await() {
try {
while (running > 0 && !fatalError) {
wait();
}
} catch (InterruptedException e) {
// ignore
Looper.getMainLooper().quitSafely();
}
}
}
@@ -104,6 +96,7 @@ public final class Server {
boolean audio = options.getAudio();
boolean sendDummyByte = options.getSendDummyByte();
prepareMainLooper();
Workarounds.apply();
List<AsyncProcessor> asyncProcessors = new ArrayList<>();
@@ -172,7 +165,7 @@ public final class Server {
});
}
completion.await();
Looper.loop(); // interrupted by the Completion implementation
} finally {
if (cleanUp != null) {
cleanUp.interrupt();
@@ -201,6 +194,20 @@ public final class Server {
}
}
private static void prepareMainLooper() {
// Like Looper.prepareMainLooper(), but with quitAllowed set to true
Looper.prepare();
synchronized (Looper.class) {
try {
Field field = Looper.class.getDeclaredField("sMainLooper");
field.setAccessible(true);
field.set(null, Looper.myLooper());
} catch (ReflectiveOperationException e) {
throw new AssertionError(e);
}
}
}
public static void main(String... args) {
int status = 0;
try {

View File

@@ -29,8 +29,6 @@ public final class Workarounds {
private static final Object ACTIVITY_THREAD;
static {
prepareMainLooper();
try {
// ActivityThread activityThread = new ActivityThread();
ACTIVITY_THREAD_CLASS = Class.forName("android.app.ActivityThread");
@@ -77,19 +75,6 @@ public final class Workarounds {
fillAppContext();
}
@SuppressWarnings("deprecation")
private static void prepareMainLooper() {
// Some devices internally create a Handler when creating an input Surface, causing an exception:
// "Can't create handler inside thread that has not called Looper.prepare()"
// <https://github.com/Genymobile/scrcpy/issues/240>
//
// Use Looper.prepareMainLooper() instead of Looper.prepare() to avoid a NullPointerException:
// "Attempt to read from field 'android.os.MessageQueue android.os.Looper.mQueue'
// on a null object reference"
// <https://github.com/Genymobile/scrcpy/issues/921>
Looper.prepareMainLooper();
}
private static void fillAppInfo() {
try {
// ActivityThread.AppBindData appBindData = new ActivityThread.AppBindData();

View File

@@ -6,6 +6,7 @@ import com.genymobile.scrcpy.CleanUp;
import com.genymobile.scrcpy.Options;
import com.genymobile.scrcpy.device.Device;
import com.genymobile.scrcpy.device.DeviceApp;
import com.genymobile.scrcpy.device.DisplayInfo;
import com.genymobile.scrcpy.device.Point;
import com.genymobile.scrcpy.device.Position;
import com.genymobile.scrcpy.device.Size;
@@ -17,7 +18,6 @@ import com.genymobile.scrcpy.wrappers.ClipboardManager;
import com.genymobile.scrcpy.wrappers.InputManager;
import com.genymobile.scrcpy.wrappers.ServiceManager;
import android.content.IOnPrimaryClipChangedListener;
import android.content.Intent;
import android.os.Build;
import android.os.SystemClock;
@@ -118,18 +118,15 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener {
// If control and autosync are enabled, synchronize Android clipboard to the computer automatically
ClipboardManager clipboardManager = ServiceManager.getClipboardManager();
if (clipboardManager != null) {
clipboardManager.addPrimaryClipChangedListener(new IOnPrimaryClipChangedListener.Stub() {
@Override
public void dispatchPrimaryClipChanged() {
if (isSettingClipboard.get()) {
// This is a notification for the change we are currently applying, ignore it
return;
}
String text = Device.getClipboardText();
if (text != null) {
DeviceMessage msg = DeviceMessage.createClipboard(text);
sender.send(msg);
}
clipboardManager.addPrimaryClipChangedListener(() -> {
if (isSettingClipboard.get()) {
// This is a notification for the change we are currently applying, ignore it
return;
}
String text = Device.getClipboardText();
if (text != null) {
DeviceMessage msg = DeviceMessage.createClipboard(text);
sender.send(msg);
}
});
} else {
@@ -156,8 +153,34 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener {
private UhidManager getUhidManager() {
if (uhidManager == null) {
uhidManager = new UhidManager(sender);
int uhidDisplayId = displayId;
if (Build.VERSION.SDK_INT >= AndroidVersions.API_35_ANDROID_15) {
if (displayId == Device.DISPLAY_ID_NONE) {
// Mirroring a new virtual display id (using --new-display-id feature) on Android >= 15, where the UHID mouse pointer can be
// associated to the virtual display
try {
// Wait for at most 1 second until a virtual display id is known
DisplayData data = waitDisplayData(1000);
if (data != null) {
uhidDisplayId = data.virtualDisplayId;
}
} catch (InterruptedException e) {
// do nothing
}
}
}
String displayUniqueId = null;
if (uhidDisplayId > 0) {
// Ignore Device.DISPLAY_ID_NONE and 0 (main display)
DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(uhidDisplayId);
if (displayInfo != null) {
displayUniqueId = displayInfo.getUniqueId();
}
}
uhidManager = new UhidManager(sender, displayUniqueId);
}
return uhidManager;
}
@@ -699,7 +722,9 @@ public class Controller implements AsyncProcessor, VirtualDisplayListener {
if (timeout < 0) {
return null;
}
displayDataAvailable.wait(timeout);
if (timeout > 0) {
displayDataAvailable.wait(timeout);
}
data = displayData.get();
}

View File

@@ -3,6 +3,7 @@ package com.genymobile.scrcpy.control;
import com.genymobile.scrcpy.AndroidVersions;
import com.genymobile.scrcpy.util.Ln;
import com.genymobile.scrcpy.util.StringUtils;
import com.genymobile.scrcpy.wrappers.ServiceManager;
import android.os.Build;
import android.os.HandlerThread;
@@ -31,14 +32,20 @@ public final class UhidManager {
private static final int SIZE_OF_UHID_EVENT = 4380; // sizeof(struct uhid_event)
// Must be unique across the system
private static final String INPUT_PORT = "scrcpy:" + Os.getpid();
private final String displayUniqueId;
private final ArrayMap<Integer, FileDescriptor> fds = new ArrayMap<>();
private final ByteBuffer buffer = ByteBuffer.allocate(SIZE_OF_UHID_EVENT).order(ByteOrder.nativeOrder());
private final DeviceMessageSender sender;
private final MessageQueue queue;
public UhidManager(DeviceMessageSender sender) {
public UhidManager(DeviceMessageSender sender, String displayUniqueId) {
this.sender = sender;
this.displayUniqueId = displayUniqueId;
if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0) {
HandlerThread thread = new HandlerThread("UHidManager");
thread.start();
@@ -52,15 +59,22 @@ public final class UhidManager {
try {
FileDescriptor fd = Os.open("/dev/uhid", OsConstants.O_RDWR, 0);
try {
// First UHID device added
boolean firstDevice = fds.isEmpty();
FileDescriptor old = fds.put(id, fd);
if (old != null) {
Ln.w("Duplicate UHID id: " + id);
close(old);
}
byte[] req = buildUhidCreate2Req(vendorId, productId, name, reportDesc);
String phys = mustUseInputPort() ? INPUT_PORT : null;
byte[] req = buildUhidCreate2Req(vendorId, productId, name, reportDesc, phys);
Os.write(fd, req, 0, req.length);
if (firstDevice) {
addUniqueIdAssociation();
}
registerUhidListener(id, fd);
} catch (Exception e) {
close(fd);
@@ -148,7 +162,7 @@ public final class UhidManager {
}
}
private static byte[] buildUhidCreate2Req(int vendorId, int productId, String name, byte[] reportDesc) {
private static byte[] buildUhidCreate2Req(int vendorId, int productId, String name, byte[] reportDesc, String phys) {
/*
* struct uhid_event {
* uint32_t type;
@@ -170,17 +184,23 @@ public final class UhidManager {
* } __attribute__((__packed__));
*/
byte[] empty = new byte[256];
ByteBuffer buf = ByteBuffer.allocate(280 + reportDesc.length).order(ByteOrder.nativeOrder());
buf.putInt(UHID_CREATE2);
String actualName = name.isEmpty() ? "scrcpy" : name;
byte[] utf8Name = actualName.getBytes(StandardCharsets.UTF_8);
int len = StringUtils.getUtf8TruncationIndex(utf8Name, 127);
assert len <= 127;
buf.put(utf8Name, 0, len);
buf.put(empty, 0, 256 - len);
byte[] nameBytes = actualName.getBytes(StandardCharsets.UTF_8);
int nameLen = StringUtils.getUtf8TruncationIndex(nameBytes, 127);
assert nameLen <= 127;
buf.put(nameBytes, 0, nameLen);
if (phys != null) {
buf.position(4 + 128);
byte[] physBytes = phys.getBytes(StandardCharsets.US_ASCII);
assert physBytes.length <= 63;
buf.put(physBytes);
}
buf.position(4 + 256);
buf.putShort((short) reportDesc.length);
buf.putShort(BUS_VIRTUAL);
buf.putInt(vendorId);
@@ -219,15 +239,26 @@ public final class UhidManager {
if (fd != null) {
unregisterUhidListener(fd);
close(fd);
if (fds.isEmpty()) {
// Last UHID device removed
removeUniqueIdAssociation();
}
} else {
Ln.w("Closing unknown UHID device: " + id);
}
}
public void closeAll() {
if (fds.isEmpty()) {
return;
}
for (FileDescriptor fd : fds.values()) {
close(fd);
}
removeUniqueIdAssociation();
}
private static void close(FileDescriptor fd) {
@@ -237,4 +268,20 @@ public final class UhidManager {
Ln.e("Failed to close uhid: " + e.getMessage());
}
}
private boolean mustUseInputPort() {
return Build.VERSION.SDK_INT >= AndroidVersions.API_35_ANDROID_15 && displayUniqueId != null;
}
private void addUniqueIdAssociation() {
if (mustUseInputPort()) {
ServiceManager.getInputManager().addUniqueIdAssociationByPort(INPUT_PORT, displayUniqueId);
}
}
private void removeUniqueIdAssociation() {
if (mustUseInputPort()) {
ServiceManager.getInputManager().removeUniqueIdAssociationByPort(INPUT_PORT);
}
}
}

View File

@@ -7,16 +7,18 @@ public final class DisplayInfo {
private final int layerStack;
private final int flags;
private final int dpi;
private final String uniqueId;
public static final int FLAG_SUPPORTS_PROTECTED_BUFFERS = 0x00000001;
public DisplayInfo(int displayId, Size size, int rotation, int layerStack, int flags, int dpi) {
public DisplayInfo(int displayId, Size size, int rotation, int layerStack, int flags, int dpi, String uniqueId) {
this.displayId = displayId;
this.size = size;
this.rotation = rotation;
this.layerStack = layerStack;
this.flags = flags;
this.dpi = dpi;
this.uniqueId = uniqueId;
}
public int getDisplayId() {
@@ -42,5 +44,8 @@ public final class DisplayInfo {
public int getDpi() {
return dpi;
}
}
public String getUniqueId() {
return uniqueId;
}
}

View File

@@ -32,9 +32,11 @@ public enum Orientation {
throw new IllegalArgumentException("Unknown orientation: " + name);
}
public static Orientation fromRotation(int rotation) {
assert rotation >= 0 && rotation < 4;
return values()[rotation];
public static Orientation fromRotation(int ccwRotation) {
assert ccwRotation >= 0 && ccwRotation < 4;
// Display rotation is expressed counter-clockwise, orientation is expressed clockwise
int cwRotation = (4 - ccwRotation) % 4;
return values()[cwRotation];
}
public boolean isFlipped() {

View File

@@ -1,270 +1,43 @@
package com.genymobile.scrcpy.wrappers;
import com.genymobile.scrcpy.AndroidVersions;
import com.genymobile.scrcpy.FakeContext;
import com.genymobile.scrcpy.util.Ln;
import android.content.ClipData;
import android.content.IOnPrimaryClipChangedListener;
import android.os.Build;
import android.os.IInterface;
import java.lang.reflect.Method;
public final class ClipboardManager {
private final IInterface manager;
private Method getPrimaryClipMethod;
private Method setPrimaryClipMethod;
private Method addPrimaryClipChangedListener;
private int getMethodVersion;
private int setMethodVersion;
private int addListenerMethodVersion;
private final android.content.ClipboardManager manager;
static ClipboardManager create() {
IInterface clipboard = ServiceManager.getService("clipboard", "android.content.IClipboard");
if (clipboard == null) {
android.content.ClipboardManager manager = (android.content.ClipboardManager) FakeContext.get()
.getSystemService(FakeContext.CLIPBOARD_SERVICE);
if (manager == null) {
// Some devices have no clipboard manager
// <https://github.com/Genymobile/scrcpy/issues/1440>
// <https://github.com/Genymobile/scrcpy/issues/1556>
return null;
}
return new ClipboardManager(clipboard);
return new ClipboardManager(manager);
}
private ClipboardManager(IInterface manager) {
private ClipboardManager(android.content.ClipboardManager manager) {
this.manager = manager;
}
private Method getGetPrimaryClipMethod() throws NoSuchMethodException {
if (getPrimaryClipMethod == null) {
if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) {
getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class);
return getPrimaryClipMethod;
}
try {
getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class, int.class);
getMethodVersion = 0;
return getPrimaryClipMethod;
} catch (NoSuchMethodException e) {
// fall-through
}
try {
getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class, String.class, int.class);
getMethodVersion = 1;
return getPrimaryClipMethod;
} catch (NoSuchMethodException e) {
// fall-through
}
try {
getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class, String.class, int.class, int.class);
getMethodVersion = 2;
return getPrimaryClipMethod;
} catch (NoSuchMethodException e) {
// fall-through
}
try {
getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class, int.class, String.class);
getMethodVersion = 3;
return getPrimaryClipMethod;
} catch (NoSuchMethodException e) {
// fall-through
}
try {
getPrimaryClipMethod = manager.getClass()
.getMethod("getPrimaryClip", String.class, String.class, int.class, int.class, boolean.class);
getMethodVersion = 4;
return getPrimaryClipMethod;
} catch (NoSuchMethodException e) {
// fall-through
}
try {
getPrimaryClipMethod = manager.getClass()
.getMethod("getPrimaryClip", String.class, String.class, String.class, String.class, int.class, int.class, boolean.class);
getMethodVersion = 5;
return getPrimaryClipMethod;
} catch (NoSuchMethodException e) {
// fall-through
}
getPrimaryClipMethod = manager.getClass().getMethod("getPrimaryClip", String.class, String.class, int.class, int.class, String.class);
getMethodVersion = 6;
}
return getPrimaryClipMethod;
}
private Method getSetPrimaryClipMethod() throws NoSuchMethodException {
if (setPrimaryClipMethod == null) {
if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) {
setPrimaryClipMethod = manager.getClass().getMethod("setPrimaryClip", ClipData.class, String.class);
return setPrimaryClipMethod;
}
try {
setPrimaryClipMethod = manager.getClass().getMethod("setPrimaryClip", ClipData.class, String.class, int.class);
setMethodVersion = 0;
return setPrimaryClipMethod;
} catch (NoSuchMethodException e1) {
// fall-through
}
try {
setPrimaryClipMethod = manager.getClass().getMethod("setPrimaryClip", ClipData.class, String.class, String.class, int.class);
setMethodVersion = 1;
return setPrimaryClipMethod;
} catch (NoSuchMethodException e2) {
// fall-through
}
try {
setPrimaryClipMethod = manager.getClass()
.getMethod("setPrimaryClip", ClipData.class, String.class, String.class, int.class, int.class);
setMethodVersion = 2;
return setPrimaryClipMethod;
} catch (NoSuchMethodException e3) {
// fall-through
}
setPrimaryClipMethod = manager.getClass()
.getMethod("setPrimaryClip", ClipData.class, String.class, String.class, int.class, int.class, boolean.class);
setMethodVersion = 3;
}
return setPrimaryClipMethod;
}
private static ClipData getPrimaryClip(Method method, int methodVersion, IInterface manager) throws ReflectiveOperationException {
if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) {
return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME);
}
switch (methodVersion) {
case 0:
return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, FakeContext.ROOT_UID);
case 1:
return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID);
case 2:
return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID, 0);
case 3:
return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, FakeContext.ROOT_UID, null);
case 4:
// The last boolean parameter is "userOperate"
return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID, 0, true);
case 5:
return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, null, null, null, FakeContext.ROOT_UID, 0, true);
default:
return (ClipData) method.invoke(manager, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID, 0, null);
}
}
private static void setPrimaryClip(Method method, int methodVersion, IInterface manager, ClipData clipData) throws ReflectiveOperationException {
if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) {
method.invoke(manager, clipData, FakeContext.PACKAGE_NAME);
return;
}
switch (methodVersion) {
case 0:
method.invoke(manager, clipData, FakeContext.PACKAGE_NAME, FakeContext.ROOT_UID);
break;
case 1:
method.invoke(manager, clipData, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID);
break;
case 2:
method.invoke(manager, clipData, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID, 0);
break;
default:
// The last boolean parameter is "userOperate"
method.invoke(manager, clipData, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID, 0, true);
}
}
public CharSequence getText() {
try {
Method method = getGetPrimaryClipMethod();
ClipData clipData = getPrimaryClip(method, getMethodVersion, manager);
if (clipData == null || clipData.getItemCount() == 0) {
return null;
}
return clipData.getItemAt(0).getText();
} catch (ReflectiveOperationException e) {
Ln.e("Could not invoke method", e);
ClipData clipData = manager.getPrimaryClip();
if (clipData == null || clipData.getItemCount() == 0) {
return null;
}
return clipData.getItemAt(0).getText();
}
public boolean setText(CharSequence text) {
try {
Method method = getSetPrimaryClipMethod();
ClipData clipData = ClipData.newPlainText(null, text);
setPrimaryClip(method, setMethodVersion, manager, clipData);
return true;
} catch (ReflectiveOperationException e) {
Ln.e("Could not invoke method", e);
return false;
}
ClipData clipData = ClipData.newPlainText(null, text);
manager.setPrimaryClip(clipData);
return true;
}
private static void addPrimaryClipChangedListener(Method method, int methodVersion, IInterface manager, IOnPrimaryClipChangedListener listener)
throws ReflectiveOperationException {
if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) {
method.invoke(manager, listener, FakeContext.PACKAGE_NAME);
return;
}
switch (methodVersion) {
case 0:
method.invoke(manager, listener, FakeContext.PACKAGE_NAME, FakeContext.ROOT_UID);
break;
case 1:
method.invoke(manager, listener, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID);
break;
default:
method.invoke(manager, listener, FakeContext.PACKAGE_NAME, null, FakeContext.ROOT_UID, 0);
break;
}
}
private Method getAddPrimaryClipChangedListener() throws NoSuchMethodException {
if (addPrimaryClipChangedListener == null) {
if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) {
addPrimaryClipChangedListener = manager.getClass()
.getMethod("addPrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class);
} else {
try {
addPrimaryClipChangedListener = manager.getClass()
.getMethod("addPrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class, int.class);
addListenerMethodVersion = 0;
} catch (NoSuchMethodException e1) {
try {
addPrimaryClipChangedListener = manager.getClass()
.getMethod("addPrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class, String.class,
int.class);
addListenerMethodVersion = 1;
} catch (NoSuchMethodException e2) {
addPrimaryClipChangedListener = manager.getClass()
.getMethod("addPrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class, String.class,
int.class, int.class);
addListenerMethodVersion = 2;
}
}
}
}
return addPrimaryClipChangedListener;
}
public boolean addPrimaryClipChangedListener(IOnPrimaryClipChangedListener listener) {
try {
Method method = getAddPrimaryClipChangedListener();
addPrimaryClipChangedListener(method, addListenerMethodVersion, manager, listener);
return true;
} catch (ReflectiveOperationException e) {
Ln.e("Could not invoke method", e);
return false;
}
public void addPrimaryClipChangedListener(android.content.ClipboardManager.OnPrimaryClipChangedListener listener) {
manager.addPrimaryClipChangedListener(listener);
}
}

View File

@@ -46,6 +46,7 @@ public final class DisplayManager {
}
private final Object manager; // instance of hidden class android.hardware.display.DisplayManagerGlobal
private Method getDisplayInfoMethod;
private Method createVirtualDisplayMethod;
private Method requestDisplayPowerMethod;
@@ -81,7 +82,7 @@ public final class DisplayManager {
int density = Integer.parseInt(m.group(5));
int layerStack = Integer.parseInt(m.group(6));
return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags, density);
return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags, density, null);
}
private static DisplayInfo getDisplayInfoFromDumpsysDisplay(int displayId) {
@@ -95,12 +96,12 @@ public final class DisplayManager {
}
private static int parseDisplayFlags(String text) {
Pattern regex = Pattern.compile("FLAG_[A-Z_]+");
if (text == null) {
return 0;
}
int flags = 0;
Pattern regex = Pattern.compile("FLAG_[A-Z_]+");
Matcher m = regex.matcher(text);
while (m.find()) {
String flagString = m.group();
@@ -114,9 +115,18 @@ public final class DisplayManager {
return flags;
}
// getDisplayInfo() may be used from both the Controller thread and the video (main) thread
private synchronized Method getGetDisplayInfoMethod() throws NoSuchMethodException {
if (getDisplayInfoMethod == null) {
getDisplayInfoMethod = manager.getClass().getMethod("getDisplayInfo", int.class);
}
return getDisplayInfoMethod;
}
public DisplayInfo getDisplayInfo(int displayId) {
try {
Object displayInfo = manager.getClass().getMethod("getDisplayInfo", int.class).invoke(manager, displayId);
Method method = getGetDisplayInfoMethod();
Object displayInfo = method.invoke(manager, displayId);
if (displayInfo == null) {
// fallback when displayInfo is null
return getDisplayInfoFromDumpsysDisplay(displayId);
@@ -129,7 +139,8 @@ public final class DisplayManager {
int layerStack = cls.getDeclaredField("layerStack").getInt(displayInfo);
int flags = cls.getDeclaredField("flags").getInt(displayInfo);
int dpi = cls.getDeclaredField("logicalDensityDpi").getInt(displayInfo);
return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags, dpi);
String uniqueId = (String) cls.getDeclaredField("uniqueId").get(displayInfo);
return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags, dpi, uniqueId);
} catch (ReflectiveOperationException e) {
throw new AssertionError(e);
}

View File

@@ -1,11 +1,15 @@
package com.genymobile.scrcpy.wrappers;
import com.genymobile.scrcpy.AndroidVersions;
import com.genymobile.scrcpy.FakeContext;
import com.genymobile.scrcpy.util.Ln;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.view.InputEvent;
import android.view.MotionEvent;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
@SuppressLint("PrivateApi,DiscouragedPrivateApi")
@@ -15,39 +19,28 @@ public final class InputManager {
public static final int INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT = 1;
public static final int INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH = 2;
private final Object manager;
private Method injectInputEventMethod;
private final android.hardware.input.InputManager manager;
private long lastPermissionLogDate;
private static Method injectInputEventMethod;
private static Method setDisplayIdMethod;
private static Method setActionButtonMethod;
private static Method addUniqueIdAssociationByPortMethod;
private static Method removeUniqueIdAssociationByPortMethod;
static InputManager create() {
try {
Class<?> inputManagerClass = getInputManagerClass();
Method getInstanceMethod = inputManagerClass.getDeclaredMethod("getInstance");
Object im = getInstanceMethod.invoke(null);
return new InputManager(im);
} catch (ReflectiveOperationException e) {
throw new AssertionError(e);
}
android.hardware.input.InputManager manager = (android.hardware.input.InputManager) FakeContext.get()
.getSystemService(FakeContext.INPUT_SERVICE);
return new InputManager(manager);
}
private static Class<?> getInputManagerClass() {
try {
// Parts of the InputManager class have been moved to a new InputManagerGlobal class in Android 14 preview
return Class.forName("android.hardware.input.InputManagerGlobal");
} catch (ClassNotFoundException e) {
return android.hardware.input.InputManager.class;
}
}
private InputManager(Object manager) {
private InputManager(android.hardware.input.InputManager manager) {
this.manager = manager;
}
private Method getInjectInputEventMethod() throws NoSuchMethodException {
private static Method getInjectInputEventMethod() throws NoSuchMethodException {
if (injectInputEventMethod == null) {
injectInputEventMethod = manager.getClass().getMethod("injectInputEvent", InputEvent.class, int.class);
injectInputEventMethod = android.hardware.input.InputManager.class.getMethod("injectInputEvent", InputEvent.class, int.class);
}
return injectInputEventMethod;
}
@@ -57,6 +50,23 @@ public final class InputManager {
Method method = getInjectInputEventMethod();
return (boolean) method.invoke(manager, inputEvent, mode);
} catch (ReflectiveOperationException e) {
if (e instanceof InvocationTargetException) {
Throwable cause = e.getCause();
if (cause instanceof SecurityException) {
String message = e.getCause().getMessage();
if (message != null && message.contains("INJECT_EVENTS permission")) {
// Do not flood the console, limit to one permission error log every 3 seconds
long now = System.currentTimeMillis();
if (lastPermissionLogDate <= now - 3000) {
Ln.e(message);
Ln.e("Make sure you have enabled \"USB debugging (Security Settings)\" and then rebooted your device.");
lastPermissionLogDate = now;
}
// Do not print the stack trace
return false;
}
}
}
Ln.e("Could not invoke method", e);
return false;
}
@@ -97,4 +107,40 @@ public final class InputManager {
return false;
}
}
private static Method getAddUniqueIdAssociationByPortMethod() throws NoSuchMethodException {
if (addUniqueIdAssociationByPortMethod == null) {
addUniqueIdAssociationByPortMethod = android.hardware.input.InputManager.class.getMethod(
"addUniqueIdAssociationByPort", String.class, String.class);
}
return addUniqueIdAssociationByPortMethod;
}
@TargetApi(AndroidVersions.API_35_ANDROID_15)
public void addUniqueIdAssociationByPort(String inputPort, String uniqueId) {
try {
Method method = getAddUniqueIdAssociationByPortMethod();
method.invoke(manager, inputPort, uniqueId);
} catch (ReflectiveOperationException e) {
Ln.e("Cannot add unique id association by port", e);
}
}
private static Method getRemoveUniqueIdAssociationByPortMethod() throws NoSuchMethodException {
if (removeUniqueIdAssociationByPortMethod == null) {
removeUniqueIdAssociationByPortMethod = android.hardware.input.InputManager.class.getMethod(
"removeUniqueIdAssociationByPort", String.class);
}
return removeUniqueIdAssociationByPortMethod;
}
@TargetApi(AndroidVersions.API_35_ANDROID_15)
public void removeUniqueIdAssociationByPort(String inputPort) {
try {
Method method = getRemoveUniqueIdAssociationByPortMethod();
method.invoke(manager, inputPort);
} catch (ReflectiveOperationException e) {
Ln.e("Cannot remove unique id association by port", e);
}
}
}

View File

@@ -54,7 +54,8 @@ public final class ServiceManager {
return windowManager;
}
public static DisplayManager getDisplayManager() {
// The DisplayManager may be used from both the Controller thread and the video (main) thread
public static synchronized DisplayManager getDisplayManager() {
if (displayManager == null) {
displayManager = DisplayManager.create();
}