diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml
index 812d060b..e65ee33b 100644
--- a/config/checkstyle/checkstyle.xml
+++ b/config/checkstyle/checkstyle.xml
@@ -139,7 +139,9 @@ page at http://checkstyle.sourceforge.net/config.html -->
-
+
+
+
diff --git a/server/build.gradle b/server/build.gradle
index 7cd7dbd7..6ebf59f1 100644
--- a/server/build.gradle
+++ b/server/build.gradle
@@ -7,7 +7,7 @@ android {
minSdkVersion 21
targetSdkVersion 30
versionCode 11900
- versionName "1.19"
+ versionName "1.19-ws1"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
@@ -20,6 +20,9 @@ android {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
+ implementation 'org.java-websocket:Java-WebSocket:1.4.0'
+ implementation 'org.slf4j:slf4j-api:1.7.25'
+ implementation 'uk.uuid.slf4j:slf4j-android:1.7.25-1'
testImplementation 'junit:junit:4.13'
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/Connection.java b/server/src/main/java/com/genymobile/scrcpy/Connection.java
new file mode 100644
index 00000000..f9a3f677
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/Connection.java
@@ -0,0 +1,118 @@
+package com.genymobile.scrcpy;
+
+import com.genymobile.scrcpy.wrappers.ContentProvider;
+
+import android.os.BatteryManager;
+import android.os.Build;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+public abstract class Connection implements Device.RotationListener, Device.ClipboardListener {
+ public interface StreamInvalidateListener {
+ void onStreamInvalidate();
+ }
+
+ protected final ControlMessageReader reader = new ControlMessageReader();
+ protected static final int DEVICE_NAME_FIELD_LENGTH = 64;
+ protected StreamInvalidateListener streamInvalidateListener;
+ protected Device device;
+ protected final VideoSettings videoSettings;
+ protected final Options options;
+ protected Controller controller;
+ protected ScreenEncoder screenEncoder;
+
+ abstract void send(ByteBuffer data);
+
+ abstract void sendDeviceMessage(DeviceMessage msg) throws IOException;
+
+ abstract void close() throws Exception;
+
+ abstract boolean hasConnections();
+
+ public Connection(Options options, VideoSettings videoSettings) {
+ Ln.i("Device: " + Build.MANUFACTURER + " " + Build.MODEL + " (Android " + Build.VERSION.RELEASE + ")");
+ this.videoSettings = videoSettings;
+ this.options = options;
+ device = new Device(options, videoSettings);
+ device.setRotationListener(this);
+ controller = new Controller(device, this);
+ startDeviceMessageSender(controller.getSender());
+ device.setClipboardListener(this);
+
+ boolean mustDisableShowTouchesOnCleanUp = false;
+ int restoreStayOn = -1;
+ if (options.getShowTouches() || options.getStayAwake()) {
+ try (ContentProvider settings = Device.createSettingsProvider()) {
+ if (options.getShowTouches()) {
+ String oldValue = settings.getAndPutValue(ContentProvider.TABLE_SYSTEM, "show_touches", "1");
+ // If "show touches" was disabled, it must be disabled back on clean up
+ mustDisableShowTouchesOnCleanUp = !"1".equals(oldValue);
+ }
+
+ if (options.getStayAwake()) {
+ int stayOn = BatteryManager.BATTERY_PLUGGED_AC | BatteryManager.BATTERY_PLUGGED_USB | BatteryManager.BATTERY_PLUGGED_WIRELESS;
+ String oldValue = settings.getAndPutValue(ContentProvider.TABLE_GLOBAL, "stay_on_while_plugged_in", String.valueOf(stayOn));
+ try {
+ restoreStayOn = Integer.parseInt(oldValue);
+ if (restoreStayOn == stayOn) {
+ // No need to restore
+ restoreStayOn = -1;
+ }
+ } catch (NumberFormatException e) {
+ restoreStayOn = 0;
+ }
+ }
+ }
+ }
+
+ try {
+ CleanUp.configure(options.getDisplayId(), restoreStayOn, mustDisableShowTouchesOnCleanUp, true, options.getPowerOffScreenOnClose());
+ } catch (IOException e) {
+ Ln.w("CleanUp.configure() failed:" + e.getMessage());
+ }
+ }
+
+ public boolean setVideoSettings(VideoSettings newSettings) {
+ if (!videoSettings.equals(newSettings)) {
+ videoSettings.merge(newSettings);
+ device.applyNewVideoSetting(videoSettings);
+ if (this.streamInvalidateListener != null) {
+ streamInvalidateListener.onStreamInvalidate();
+ }
+ return true;
+ }
+ return false;
+ }
+
+ public void setStreamInvalidateListener(StreamInvalidateListener listener) {
+ this.streamInvalidateListener = listener;
+ }
+
+ @Override
+ public void onRotationChanged(int rotation) {
+ if (this.streamInvalidateListener != null) {
+ streamInvalidateListener.onStreamInvalidate();
+ }
+ }
+
+ @Override
+ public void onClipboardTextChanged(String text) {
+ controller.getSender().pushClipboardText(text);
+ }
+
+
+ private static void startDeviceMessageSender(final DeviceMessageSender sender) {
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ sender.loop();
+ } catch (IOException | InterruptedException e) {
+ // this is expected on close
+ Ln.d("Device message sender stopped");
+ }
+ }
+ }).start();
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/ControlMessage.java b/server/src/main/java/com/genymobile/scrcpy/ControlMessage.java
index f8edd53c..95db27d8 100644
--- a/server/src/main/java/com/genymobile/scrcpy/ControlMessage.java
+++ b/server/src/main/java/com/genymobile/scrcpy/ControlMessage.java
@@ -1,5 +1,8 @@
package com.genymobile.scrcpy;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+
/**
* Union of all supported event types, identified by their {@code type}.
*/
@@ -17,6 +20,14 @@ public final class ControlMessage {
public static final int TYPE_SET_CLIPBOARD = 9;
public static final int TYPE_SET_SCREEN_POWER_MODE = 10;
public static final int TYPE_ROTATE_DEVICE = 11;
+ public static final int TYPE_CHANGE_STREAM_PARAMETERS = 101;
+ public static final int TYPE_PUSH_FILE = 102;
+
+ public static final int PUSH_STATE_NEW = 0;
+ public static final int PUSH_STATE_START = 1;
+ public static final int PUSH_STATE_APPEND = 2;
+ public static final int PUSH_STATE_FINISH = 3;
+ public static final int PUSH_STATE_CANCEL = 4;
private int type;
private String text;
@@ -31,6 +42,14 @@ public final class ControlMessage {
private int vScroll;
private boolean paste;
private int repeat;
+ private byte[] bytes;
+ private short pushId;
+ private int pushState;
+ private byte[] pushChunk;
+ private int pushChunkSize;
+ private int fileSize;
+ private String fileName;
+ private VideoSettings videoSettings;
private ControlMessage() {
}
@@ -97,6 +116,50 @@ public final class ControlMessage {
return msg;
}
+ public static ControlMessage createChangeSteamParameters(byte[] bytes) {
+ ControlMessage event = new ControlMessage();
+ event.type = TYPE_CHANGE_STREAM_PARAMETERS;
+ event.videoSettings = VideoSettings.fromByteArray(bytes);
+ return event;
+ }
+
+ public static ControlMessage createFilePush(byte[] bytes) {
+ ControlMessage event = new ControlMessage();
+ event.type = TYPE_PUSH_FILE;
+ ByteBuffer buffer = ByteBuffer.wrap(bytes);
+ event.pushId = buffer.getShort();
+ event.pushState = buffer.get();
+ switch (event.pushState) {
+ case PUSH_STATE_START:
+ event.fileSize = buffer.getInt();
+ short nameLength = buffer.getShort();
+ byte[] textBuffer = new byte[nameLength];
+ buffer.get(textBuffer, 0, nameLength);
+ event.fileName = new String(textBuffer, 0, nameLength, StandardCharsets.UTF_8);
+ break;
+ case PUSH_STATE_APPEND:
+ int chunkSize = buffer.getInt();
+ byte[] chunk = new byte[chunkSize];
+ if (buffer.remaining() >= chunkSize) {
+ buffer.get(chunk, 0, chunkSize);
+ event.pushChunkSize = chunkSize;
+ event.pushChunk = chunk;
+ } else {
+ event.pushState = PUSH_STATE_CANCEL;
+ }
+ break;
+ case PUSH_STATE_NEW:
+ case PUSH_STATE_CANCEL:
+ case PUSH_STATE_FINISH:
+ break;
+ // nothing special;
+ default:
+ Ln.w("Unknown push event state: " + event.pushState);
+ return null;
+ }
+ return event;
+ }
+
public static ControlMessage createEmpty(int type) {
ControlMessage msg = new ControlMessage();
msg.type = type;
@@ -154,4 +217,36 @@ public final class ControlMessage {
public int getRepeat() {
return repeat;
}
+
+ public byte[] getBytes() {
+ return bytes;
+ }
+
+ public short getPushId() {
+ return pushId;
+ }
+
+ public int getPushState() {
+ return pushState;
+ }
+
+ public byte[] getPushChunk() {
+ return pushChunk;
+ }
+
+ public int getPushChunkSize() {
+ return pushChunkSize;
+ }
+
+ public String getFileName() {
+ return fileName;
+ }
+
+ public int getFileSize() {
+ return fileSize;
+ }
+
+ public VideoSettings getVideoSettings() {
+ return videoSettings;
+ }
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/ControlMessageReader.java b/server/src/main/java/com/genymobile/scrcpy/ControlMessageReader.java
index e4ab8402..3015679e 100644
--- a/server/src/main/java/com/genymobile/scrcpy/ControlMessageReader.java
+++ b/server/src/main/java/com/genymobile/scrcpy/ControlMessageReader.java
@@ -47,6 +47,10 @@ public class ControlMessageReader {
}
public ControlMessage next() {
+ return parseEvent(buffer);
+ }
+
+ public ControlMessage parseEvent(ByteBuffer buffer) {
if (!buffer.hasRemaining()) {
return null;
}
@@ -56,25 +60,31 @@ public class ControlMessageReader {
ControlMessage msg;
switch (type) {
case ControlMessage.TYPE_INJECT_KEYCODE:
- msg = parseInjectKeycode();
+ msg = parseInjectKeycode(buffer);
break;
case ControlMessage.TYPE_INJECT_TEXT:
- msg = parseInjectText();
+ msg = parseInjectText(buffer);
break;
case ControlMessage.TYPE_INJECT_TOUCH_EVENT:
- msg = parseInjectTouchEvent();
+ msg = parseInjectTouchEvent(buffer);
break;
case ControlMessage.TYPE_INJECT_SCROLL_EVENT:
- msg = parseInjectScrollEvent();
+ msg = parseInjectScrollEvent(buffer);
break;
case ControlMessage.TYPE_BACK_OR_SCREEN_ON:
- msg = parseBackOrScreenOnEvent();
+ msg = parseBackOrScreenOnEvent(buffer);
break;
case ControlMessage.TYPE_SET_CLIPBOARD:
- msg = parseSetClipboard();
+ msg = parseSetClipboard(buffer);
break;
case ControlMessage.TYPE_SET_SCREEN_POWER_MODE:
- msg = parseSetScreenPowerMode();
+ msg = parseSetScreenPowerMode(buffer);
+ break;
+ case ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS:
+ msg = parseChangeStreamParameters(buffer);
+ break;
+ case ControlMessage.TYPE_PUSH_FILE:
+ msg = parsePushFile(buffer);
break;
case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL:
case ControlMessage.TYPE_EXPAND_SETTINGS_PANEL:
@@ -96,7 +106,25 @@ public class ControlMessageReader {
return msg;
}
- private ControlMessage parseInjectKeycode() {
+ private ControlMessage parseChangeStreamParameters(ByteBuffer buffer) {
+ int re = buffer.remaining();
+ byte[] bytes = new byte[re];
+ if (re > 0) {
+ buffer.get(bytes, 0, re);
+ }
+ return ControlMessage.createChangeSteamParameters(bytes);
+ }
+
+ private ControlMessage parsePushFile(ByteBuffer buffer) {
+ int re = buffer.remaining();
+ byte[] bytes = new byte[re];
+ if (re > 0) {
+ buffer.get(bytes, 0, re);
+ }
+ return ControlMessage.createFilePush(bytes);
+ }
+
+ private ControlMessage parseInjectKeycode(ByteBuffer buffer) {
if (buffer.remaining() < INJECT_KEYCODE_PAYLOAD_LENGTH) {
return null;
}
@@ -107,7 +135,7 @@ public class ControlMessageReader {
return ControlMessage.createInjectKeycode(action, keycode, repeat, metaState);
}
- private String parseString() {
+ private String parseString(ByteBuffer buffer) {
if (buffer.remaining() < 4) {
return null;
}
@@ -115,21 +143,19 @@ public class ControlMessageReader {
if (buffer.remaining() < len) {
return null;
}
- int position = buffer.position();
- // Move the buffer position to consume the text
- buffer.position(position + len);
- return new String(rawBuffer, position, len, StandardCharsets.UTF_8);
+ buffer.get(rawBuffer, 0, len);
+ return new String(rawBuffer, 0, len, StandardCharsets.UTF_8);
}
- private ControlMessage parseInjectText() {
- String text = parseString();
+ private ControlMessage parseInjectText(ByteBuffer buffer) {
+ String text = parseString(buffer);
if (text == null) {
return null;
}
return ControlMessage.createInjectText(text);
}
- private ControlMessage parseInjectTouchEvent() {
+ private ControlMessage parseInjectTouchEvent(ByteBuffer buffer) {
if (buffer.remaining() < INJECT_TOUCH_EVENT_PAYLOAD_LENGTH) {
return null;
}
@@ -144,7 +170,7 @@ public class ControlMessageReader {
return ControlMessage.createInjectTouchEvent(action, pointerId, position, pressure, buttons);
}
- private ControlMessage parseInjectScrollEvent() {
+ private ControlMessage parseInjectScrollEvent(ByteBuffer buffer) {
if (buffer.remaining() < INJECT_SCROLL_EVENT_PAYLOAD_LENGTH) {
return null;
}
@@ -154,7 +180,7 @@ public class ControlMessageReader {
return ControlMessage.createInjectScrollEvent(position, hScroll, vScroll);
}
- private ControlMessage parseBackOrScreenOnEvent() {
+ private ControlMessage parseBackOrScreenOnEvent(ByteBuffer buffer) {
if (buffer.remaining() < BACK_OR_SCREEN_ON_LENGTH) {
return null;
}
@@ -162,19 +188,19 @@ public class ControlMessageReader {
return ControlMessage.createBackOrScreenOn(action);
}
- private ControlMessage parseSetClipboard() {
+ private ControlMessage parseSetClipboard(ByteBuffer buffer) {
if (buffer.remaining() < SET_CLIPBOARD_FIXED_PAYLOAD_LENGTH) {
return null;
}
boolean paste = buffer.get() != 0;
- String text = parseString();
+ String text = parseString(buffer);
if (text == null) {
return null;
}
return ControlMessage.createSetClipboard(text, paste);
}
- private ControlMessage parseSetScreenPowerMode() {
+ private ControlMessage parseSetScreenPowerMode(ByteBuffer buffer) {
if (buffer.remaining() < SET_SCREEN_POWER_MODE_PAYLOAD_LENGTH) {
return null;
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/Controller.java b/server/src/main/java/com/genymobile/scrcpy/Controller.java
index 45882bb9..35b2c4bd 100644
--- a/server/src/main/java/com/genymobile/scrcpy/Controller.java
+++ b/server/src/main/java/com/genymobile/scrcpy/Controller.java
@@ -19,7 +19,7 @@ public class Controller {
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor();
private final Device device;
- private final DesktopConnection connection;
+ private final Connection connection;
private final DeviceMessageSender sender;
private final KeyCharacterMap charMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD);
@@ -31,7 +31,7 @@ public class Controller {
private boolean keepPowerModeOff;
- public Controller(Device device, DesktopConnection connection) {
+ public Controller(Device device, Connection connection) {
this.device = device;
this.connection = connection;
initPointers();
@@ -52,32 +52,11 @@ public class Controller {
}
}
- public void control() throws IOException {
- // on start, power on the device
- if (!Device.isScreenOn()) {
- device.pressReleaseKeycode(KeyEvent.KEYCODE_POWER);
-
- // dirty hack
- // After POWER is injected, the device is powered on asynchronously.
- // To turn the device screen off while mirroring, the client will send a message that
- // would be handled before the device is actually powered on, so its effect would
- // be "canceled" once the device is turned back on.
- // Adding this delay prevents to handle the message before the device is actually
- // powered on.
- SystemClock.sleep(500);
- }
-
- while (true) {
- handleEvent();
- }
- }
-
public DeviceMessageSender getSender() {
return sender;
}
- private void handleEvent() throws IOException {
- ControlMessage msg = connection.receiveControlMessage();
+ public void handleEvent(ControlMessage msg) {
switch (msg.getType()) {
case ControlMessage.TYPE_INJECT_KEYCODE:
if (device.supportsInputEvents()) {
@@ -116,7 +95,12 @@ public class Controller {
case ControlMessage.TYPE_GET_CLIPBOARD:
String clipboardText = Device.getClipboardText();
if (clipboardText != null) {
- sender.pushClipboardText(clipboardText);
+ DeviceMessage event = DeviceMessage.createClipboard(clipboardText);
+ try {
+ connection.sendDeviceMessage(event);
+ } catch (IOException e) {
+ Ln.w("");
+ }
}
break;
case ControlMessage.TYPE_SET_CLIPBOARD:
@@ -289,4 +273,8 @@ public class Controller {
return ok;
}
+
+ public void turnScreenOn() {
+ device.pressReleaseKeycode(KeyEvent.KEYCODE_POWER);
+ }
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/DesktopConnection.java b/server/src/main/java/com/genymobile/scrcpy/DesktopConnection.java
index 0ec43040..e5a6bb85 100644
--- a/server/src/main/java/com/genymobile/scrcpy/DesktopConnection.java
+++ b/server/src/main/java/com/genymobile/scrcpy/DesktopConnection.java
@@ -3,17 +3,16 @@ package com.genymobile.scrcpy;
import android.net.LocalServerSocket;
import android.net.LocalSocket;
import android.net.LocalSocketAddress;
+import android.os.SystemClock;
-import java.io.Closeable;
import java.io.FileDescriptor;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
-public final class DesktopConnection implements Closeable {
-
- private static final int DEVICE_NAME_FIELD_LENGTH = 64;
+public final class DesktopConnection extends Connection {
private static final String SOCKET_NAME = "scrcpy";
@@ -24,26 +23,17 @@ public final class DesktopConnection implements Closeable {
private final InputStream controlInputStream;
private final OutputStream controlOutputStream;
- private final ControlMessageReader reader = new ControlMessageReader();
private final DeviceMessageWriter writer = new DeviceMessageWriter();
- private DesktopConnection(LocalSocket videoSocket, LocalSocket controlSocket) throws IOException {
- this.videoSocket = videoSocket;
- this.controlSocket = controlSocket;
- controlInputStream = controlSocket.getInputStream();
- controlOutputStream = controlSocket.getOutputStream();
- videoFd = videoSocket.getFileDescriptor();
- }
-
private static LocalSocket connect(String abstractName) throws IOException {
LocalSocket localSocket = new LocalSocket();
localSocket.connect(new LocalSocketAddress(abstractName));
return localSocket;
}
- public static DesktopConnection open(Device device, boolean tunnelForward) throws IOException {
- LocalSocket videoSocket;
- LocalSocket controlSocket;
+ public DesktopConnection(Options options, VideoSettings videoSettings) throws IOException {
+ super(options, videoSettings);
+ boolean tunnelForward = options.isTunnelForward();
if (tunnelForward) {
LocalServerSocket localServerSocket = new LocalServerSocket(SOCKET_NAME);
try {
@@ -69,10 +59,18 @@ public final class DesktopConnection implements Closeable {
}
}
- DesktopConnection connection = new DesktopConnection(videoSocket, controlSocket);
+ controlInputStream = controlSocket.getInputStream();
+ controlOutputStream = controlSocket.getOutputStream();
+ videoFd = videoSocket.getFileDescriptor();
+ if (options.getControl()) {
+ startEventController();
+ }
Size videoSize = device.getScreenInfo().getVideoSize();
- connection.send(Device.getDeviceName(), videoSize.getWidth(), videoSize.getHeight());
- return connection;
+ send(Device.getDeviceName(), videoSize.getWidth(), videoSize.getHeight());
+ screenEncoder = new ScreenEncoder(videoSettings);
+ screenEncoder.setDevice(device);
+ screenEncoder.setConnection(this);
+ screenEncoder.run();
}
public void close() throws IOException {
@@ -99,10 +97,56 @@ public final class DesktopConnection implements Closeable {
IO.writeFully(videoFd, buffer, 0, buffer.length);
}
+ public void send(ByteBuffer data) {
+ try {
+ IO.writeFully(videoFd, data);
+ } catch (IOException e) {
+ Ln.e("Failed to send data", e);
+ }
+ }
+
+ @Override
+ boolean hasConnections() {
+ return true;
+ }
+
public FileDescriptor getVideoFd() {
return videoFd;
}
+ private void startEventController() {
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ // on start, power on the device
+ if (!Device.isScreenOn()) {
+ controller.turnScreenOn();
+
+ // dirty hack
+ // After POWER is injected, the device is powered on asynchronously.
+ // To turn the device screen off while mirroring, the client will send a message that
+ // would be handled before the device is actually powered on, so its effect would
+ // be "canceled" once the device is turned back on.
+ // Adding this delay prevents to handle the message before the device is actually
+ // powered on.
+ SystemClock.sleep(500);
+ }
+ while (true) {
+ ControlMessage controlEvent = receiveControlMessage();
+ if (controlEvent != null) {
+ controller.handleEvent(controlEvent);
+ }
+ }
+
+ } catch (IOException e) {
+ // this is expected on close
+ Ln.d("Event controller stopped");
+ }
+ }
+ }).start();
+ }
+
public ControlMessage receiveControlMessage() throws IOException {
ControlMessage msg = reader.next();
while (msg == null) {
diff --git a/server/src/main/java/com/genymobile/scrcpy/Device.java b/server/src/main/java/com/genymobile/scrcpy/Device.java
index 3e71fe9c..49bae007 100644
--- a/server/src/main/java/com/genymobile/scrcpy/Device.java
+++ b/server/src/main/java/com/genymobile/scrcpy/Device.java
@@ -55,24 +55,26 @@ public final class Device {
private final boolean supportsInputEvents;
- public Device(Options options) {
- displayId = options.getDisplayId();
- DisplayInfo displayInfo = SERVICE_MANAGER.getDisplayManager().getDisplayInfo(displayId);
+ private IRotationWatcher rotationWatcher;
+ private IOnPrimaryClipChangedListener clipChangedListener;
+
+ public Device(final Options options, final VideoSettings videoSettings) {
+ displayId = videoSettings.getDisplayId();
+ final DisplayInfo displayInfo = Device.getDisplayInfo(displayId);
if (displayInfo == null) {
- int[] displayIds = SERVICE_MANAGER.getDisplayManager().getDisplayIds();
- throw new InvalidDisplayIdException(displayId, displayIds);
+ throw new InvalidDisplayIdException(displayId, Device.getDisplayIds());
}
int displayInfoFlags = displayInfo.getFlags();
- screenInfo = ScreenInfo.computeScreenInfo(displayInfo, options.getCrop(), options.getMaxSize(), options.getLockedVideoOrientation());
+ screenInfo = ScreenInfo.computeScreenInfo(displayInfo, videoSettings);
layerStack = displayInfo.getLayerStack();
- SERVICE_MANAGER.getWindowManager().registerRotationWatcher(new IRotationWatcher.Stub() {
+ rotationWatcher = new IRotationWatcher.Stub() {
@Override
public void onRotationChanged(int rotation) {
synchronized (Device.this) {
- screenInfo = screenInfo.withDeviceRotation(rotation);
+ applyNewVideoSetting(videoSettings);
// notify
if (rotationListener != null) {
@@ -80,29 +82,32 @@ public final class Device {
}
}
}
- }, displayId);
+ };
+
+ SERVICE_MANAGER.getWindowManager().registerRotationWatcher(rotationWatcher, displayId);
if (options.getControl()) {
// If control is enabled, synchronize Android clipboard to the computer automatically
ClipboardManager clipboardManager = SERVICE_MANAGER.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;
- }
- synchronized (Device.this) {
- if (clipboardListener != null) {
- String text = getClipboardText();
- if (text != null) {
- clipboardListener.onClipboardTextChanged(text);
- }
+ clipChangedListener = new IOnPrimaryClipChangedListener.Stub() {
+ @Override
+ public void dispatchPrimaryClipChanged() {
+ if (isSettingClipboard.get()) {
+ // This is a notification for the change we are currently applying, ignore it
+ return;
+ }
+ synchronized (Device.this) {
+ if (clipboardListener != null) {
+ String text = getClipboardText();
+ if (text != null) {
+ clipboardListener.onClipboardTextChanged(text);
}
}
}
- });
+ }
+ };
+ if (clipboardManager != null) {
+ clipboardManager.addPrimaryClipChangedListener(clipChangedListener);
} else {
Ln.w("No clipboard manager, copy-paste between device and computer will not work");
}
@@ -127,6 +132,14 @@ public final class Device {
return layerStack;
}
+ public void applyNewVideoSetting(VideoSettings videoSettings) {
+ this.setScreenInfo(ScreenInfo.computeScreenInfo(Device.getDisplayInfo(displayId), videoSettings));
+ }
+
+ public synchronized void setScreenInfo(ScreenInfo screenInfo) {
+ this.screenInfo = screenInfo;
+ }
+
public Point getPhysicalPoint(Position position) {
// it hides the field on purpose, to read it with a lock
@SuppressWarnings("checkstyle:HiddenField")
@@ -256,6 +269,22 @@ public final class Device {
return ok;
}
+ public void release() {
+ if (clipChangedListener != null) {
+ ClipboardManager clipboardManager = SERVICE_MANAGER.getClipboardManager();
+ if (clipboardManager != null) {
+ clipboardManager.removePrimaryClipChangedListener(clipChangedListener);
+ }
+ this.clipboardListener = null;
+ }
+ if (rotationWatcher != null) {
+ SERVICE_MANAGER.getWindowManager().unregisterRotationWatcher(rotationWatcher);
+ rotationWatcher = null;
+ }
+ this.rotationListener = null;
+ this.clipboardListener = null;
+ }
+
/**
* @param mode one of the {@code POWER_MODE_*} constants
*/
@@ -299,4 +328,12 @@ public final class Device {
public static ContentProvider createSettingsProvider() {
return SERVICE_MANAGER.getActivityManager().createSettingsProvider();
}
+
+ public static int[] getDisplayIds() {
+ return SERVICE_MANAGER.getDisplayManager().getDisplayIds();
+ }
+
+ public static DisplayInfo getDisplayInfo(int displayId) {
+ return SERVICE_MANAGER.getDisplayManager().getDisplayInfo(displayId);
+ }
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/DeviceMessage.java b/server/src/main/java/com/genymobile/scrcpy/DeviceMessage.java
index c6eebd38..ecc43b55 100644
--- a/server/src/main/java/com/genymobile/scrcpy/DeviceMessage.java
+++ b/server/src/main/java/com/genymobile/scrcpy/DeviceMessage.java
@@ -1,27 +1,83 @@
package com.genymobile.scrcpy;
-public final class DeviceMessage {
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+public abstract class DeviceMessage {
+ private static final int MESSAGE_MAX_SIZE = 1 << 18; // 256k
+ public static final int MAX_EVENT_SIZE = 4096;
public static final int TYPE_CLIPBOARD = 0;
+ public static final int TYPE_PUSH_RESPONSE = 101;
private int type;
- private String text;
- private DeviceMessage() {
+ private DeviceMessage(int type) {
+ this.type = type;
+ }
+
+ private static final class ClipboardMessage extends DeviceMessage {
+ public static final int CLIPBOARD_TEXT_MAX_LENGTH = MESSAGE_MAX_SIZE - 5; // type: 1 byte; length: 4 bytes
+ private byte[] raw;
+ private int len;
+ private ClipboardMessage(String text) {
+ super(TYPE_CLIPBOARD);
+ this.raw = text.getBytes(StandardCharsets.UTF_8);
+ this.len = StringUtils.getUtf8TruncationIndex(raw, CLIPBOARD_TEXT_MAX_LENGTH);
+ }
+ public void writeToByteArray(byte[] array, int offset) {
+ ByteBuffer buffer = ByteBuffer.wrap(array, offset, array.length - offset);
+ buffer.put((byte) this.getType());
+ buffer.putInt(len);
+ buffer.put(raw, 0, len);
+ }
+ public int getLen() {
+ return 5 + len;
+ }
+ }
+
+ private static final class FilePushResponseMessage extends DeviceMessage {
+ private short id;
+ private int result;
+
+ private FilePushResponseMessage(short id, int result) {
+ super(TYPE_PUSH_RESPONSE);
+ this.id = id;
+ this.result = result;
+ }
+
+ @Override
+ public void writeToByteArray(byte[] array, int offset) {
+ ByteBuffer buffer = ByteBuffer.wrap(array, offset, array.length - offset);
+ buffer.put((byte) this.getType());
+ buffer.putShort(id);
+ buffer.put((byte) result);
+ }
+
+ @Override
+ public int getLen() {
+ return 4;
+ }
}
public static DeviceMessage createClipboard(String text) {
- DeviceMessage event = new DeviceMessage();
- event.type = TYPE_CLIPBOARD;
- event.text = text;
- return event;
+ return new ClipboardMessage(text);
+ }
+
+ public static DeviceMessage createPushResponse(short id, int result) {
+ return new FilePushResponseMessage(id, result);
}
public int getType() {
return type;
}
-
- public String getText() {
- return text;
+ public void writeToByteArray(byte[] array) {
+ writeToByteArray(array, 0);
}
+ public byte[] writeToByteArray(int offset) {
+ byte[] temp = new byte[offset + this.getLen()];
+ writeToByteArray(temp, offset);
+ return temp;
+ }
+ public abstract void writeToByteArray(byte[] array, int offset);
+ public abstract int getLen();
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/DeviceMessageSender.java b/server/src/main/java/com/genymobile/scrcpy/DeviceMessageSender.java
index bbf4dd2e..1f3d063a 100644
--- a/server/src/main/java/com/genymobile/scrcpy/DeviceMessageSender.java
+++ b/server/src/main/java/com/genymobile/scrcpy/DeviceMessageSender.java
@@ -4,11 +4,11 @@ import java.io.IOException;
public final class DeviceMessageSender {
- private final DesktopConnection connection;
+ private final Connection connection;
private String clipboardText;
- public DeviceMessageSender(DesktopConnection connection) {
+ public DeviceMessageSender(Connection connection) {
this.connection = connection;
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/DeviceMessageWriter.java b/server/src/main/java/com/genymobile/scrcpy/DeviceMessageWriter.java
index 15d91a35..48c581a0 100644
--- a/server/src/main/java/com/genymobile/scrcpy/DeviceMessageWriter.java
+++ b/server/src/main/java/com/genymobile/scrcpy/DeviceMessageWriter.java
@@ -2,32 +2,13 @@ package com.genymobile.scrcpy;
import java.io.IOException;
import java.io.OutputStream;
-import java.nio.ByteBuffer;
-import java.nio.charset.StandardCharsets;
public class DeviceMessageWriter {
- private static final int MESSAGE_MAX_SIZE = 1 << 18; // 256k
- public static final int CLIPBOARD_TEXT_MAX_LENGTH = MESSAGE_MAX_SIZE - 5; // type: 1 byte; length: 4 bytes
-
- private final byte[] rawBuffer = new byte[MESSAGE_MAX_SIZE];
- private final ByteBuffer buffer = ByteBuffer.wrap(rawBuffer);
+ private final byte[] rawBuffer = new byte[DeviceMessage.MAX_EVENT_SIZE];
public void writeTo(DeviceMessage msg, OutputStream output) throws IOException {
- buffer.clear();
- buffer.put((byte) DeviceMessage.TYPE_CLIPBOARD);
- switch (msg.getType()) {
- case DeviceMessage.TYPE_CLIPBOARD:
- String text = msg.getText();
- byte[] raw = text.getBytes(StandardCharsets.UTF_8);
- int len = StringUtils.getUtf8TruncationIndex(raw, CLIPBOARD_TEXT_MAX_LENGTH);
- buffer.putInt(len);
- buffer.put(raw, 0, len);
- output.write(rawBuffer, 0, buffer.position());
- break;
- default:
- Ln.w("Unknown device message: " + msg.getType());
- break;
- }
+ msg.writeToByteArray(rawBuffer);
+ output.write(rawBuffer, 0, msg.getLen());
}
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/DisplayInfo.java b/server/src/main/java/com/genymobile/scrcpy/DisplayInfo.java
index 4b8036f8..55ff8f3a 100644
--- a/server/src/main/java/com/genymobile/scrcpy/DisplayInfo.java
+++ b/server/src/main/java/com/genymobile/scrcpy/DisplayInfo.java
@@ -1,5 +1,7 @@
package com.genymobile.scrcpy;
+import java.nio.ByteBuffer;
+
public final class DisplayInfo {
private final int displayId;
private final Size size;
@@ -36,5 +38,16 @@ public final class DisplayInfo {
public int getFlags() {
return flags;
}
-}
+ public byte[] toByteArray() {
+ ByteBuffer temp = ByteBuffer.allocate(24);
+ temp.putInt(displayId);
+ temp.putInt(size.getWidth());
+ temp.putInt(size.getHeight());
+ temp.putInt(rotation);
+ temp.putInt(layerStack);
+ temp.putInt(flags);
+ temp.rewind();
+ return temp.array();
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/FilePushHandler.java b/server/src/main/java/com/genymobile/scrcpy/FilePushHandler.java
new file mode 100644
index 00000000..6e95034a
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/FilePushHandler.java
@@ -0,0 +1,247 @@
+package com.genymobile.scrcpy;
+
+import org.java_websocket.WebSocket;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.HashMap;
+
+public final class FilePushHandler {
+ private static final int NEW_PUSH_ID = 1;
+ private static final int NO_ERROR = 0;
+ private static final int ERROR_INVALID_NAME = -1;
+ private static final int ERROR_NO_SPACE = -2;
+ private static final int ERROR_FAILED_TO_DELETE = -3;
+ private static final int ERROR_FAILED_TO_CREATE = -4;
+ private static final int ERROR_FILE_NOT_FOUND = -5;
+ private static final int ERROR_FAILED_TO_WRITE = -6;
+ private static final int ERROR_FILE_IS_BUSY = -7;
+ private static final int ERROR_INVALID_STATE = -8;
+ private static final int ERROR_UNKNOWN_ID = -9;
+ private static final int ERROR_NO_FREE_ID = -10;
+ private static final int ERROR_INCORRECT_SIZE = -11;
+
+ private static final String PUSH_PATH = "/data/local/tmp";
+
+ private FilePushHandler() {
+ }
+
+ private static final class FilePush {
+ private static final HashMap INSTANCES_BY_NAME = new HashMap<>();
+ private static final HashMap INSTANCES_BY_ID = new HashMap<>();
+ private static short nextPushId = 0;
+
+ private final FileOutputStream stream;
+ private final WebSocket conn;
+ private final short pushId;
+ private final String fileName;
+ private final long fileSize;
+ private long processedBytes = 0;
+
+
+ FilePush(short pushId, long fileSize, String fileName, FileOutputStream stream, WebSocket conn) {
+ this.pushId = pushId;
+ this.fileSize = fileSize;
+ this.fileName = fileName;
+ this.stream = stream;
+ this.conn = conn;
+ INSTANCES_BY_ID.put(pushId, this);
+ INSTANCES_BY_NAME.put(fileName, this);
+ }
+ public static FilePush getInstance(String fileName) {
+ return INSTANCES_BY_NAME.get(fileName);
+ }
+ public static FilePush getInstance(short id) {
+ return INSTANCES_BY_ID.get(id);
+ }
+ public static short getNextPushId() {
+ short current = nextPushId;
+ while (INSTANCES_BY_ID.containsKey(++nextPushId)) {
+ if (nextPushId == Short.MAX_VALUE) {
+ nextPushId = 0;
+ }
+ if (nextPushId == current) {
+ return -1;
+ }
+ }
+ return nextPushId;
+ }
+ public static void releaseByConnection(WebSocket conn) {
+ ArrayList related = new ArrayList<>();
+ for (FilePush filePush: INSTANCES_BY_ID.values()) {
+ if (filePush.conn.equals(conn)) {
+ related.add(filePush);
+ }
+ }
+ for (FilePush filePush: related) {
+ try {
+ filePush.release();
+ } catch (IOException e) {
+ Ln.w("Failed to release stream for file: \"" + filePush.getFileName() + "\"");
+ }
+ }
+ }
+ public void write(byte[] chunk, int len) throws IOException {
+ processedBytes += len;
+ stream.write(chunk, 0, len);
+ }
+ public String getFileName() {
+ return fileName;
+ }
+ public boolean isComplete() {
+ return processedBytes == fileSize;
+ }
+ public void release() throws IOException {
+ INSTANCES_BY_ID.remove(pushId);
+ INSTANCES_BY_NAME.remove(fileName);
+ this.stream.close();
+ }
+ }
+
+
+ private static ByteBuffer pushFilePushResponse(short id, int result) {
+ DeviceMessage msg = DeviceMessage.createPushResponse(id, result);
+ return WebSocketConnection.deviceMessageToByteBuffer(msg);
+ }
+
+ private static FilePush checkPushId(WebSocket conn, ControlMessage msg) {
+ short pushId = msg.getPushId();
+ FilePush filePush = FilePush.getInstance(pushId);
+ if (filePush == null) {
+ conn.send(pushFilePushResponse(pushId, ERROR_UNKNOWN_ID));
+ }
+ return filePush;
+ }
+
+ private static void handleNew(WebSocket conn) {
+ short newPushId = FilePush.getNextPushId();
+ if (newPushId == -1) {
+ conn.send(FilePushHandler.pushFilePushResponse(newPushId, ERROR_NO_FREE_ID));
+ } else {
+ conn.send(FilePushHandler.pushFilePushResponse(newPushId, NEW_PUSH_ID));
+ }
+ }
+ private static void handleStart(WebSocket conn, ControlMessage msg) {
+ short pushId = msg.getPushId();
+ String fileName = msg.getFileName();
+ int fileSize = msg.getFileSize();
+ if (FilePush.getInstance(fileName) != null) {
+ conn.send(pushFilePushResponse(pushId, ERROR_FILE_IS_BUSY));
+ return;
+ }
+ if (fileName.contains("/")) {
+ conn.send(pushFilePushResponse(pushId, ERROR_INVALID_NAME));
+ return;
+ }
+ File file = new File(PUSH_PATH, fileName);
+
+// long freeSpace = file.getFreeSpace();
+// if (freeSpace < fileSize) {
+// sender.send(pushFilePushResponse(pushId, ERROR_NO_SPACE));
+// return;
+// }
+
+ try {
+ if (!file.createNewFile()) {
+ if (!file.delete()) {
+ conn.send(pushFilePushResponse(pushId, ERROR_FAILED_TO_DELETE));
+ return;
+ }
+ }
+ } catch (IOException e) {
+ conn.send(pushFilePushResponse(pushId, ERROR_FAILED_TO_CREATE));
+ return;
+ }
+
+ FileOutputStream stream;
+ try {
+ stream = new FileOutputStream(file);
+ } catch (FileNotFoundException e) {
+ conn.send(pushFilePushResponse(pushId, ERROR_FILE_NOT_FOUND));
+ return;
+ }
+
+ new FilePush(pushId, fileSize, fileName, stream, conn);
+ conn.send(pushFilePushResponse(pushId, NO_ERROR));
+
+ }
+ private static void handleAppend(WebSocket conn, ControlMessage msg) {
+ FilePush filePush = checkPushId(conn, msg);
+ if (filePush == null) {
+ return;
+ }
+ short pushId = msg.getPushId();
+ try {
+ filePush.write(msg.getPushChunk(), msg.getPushChunkSize());
+ conn.send(pushFilePushResponse(pushId, NO_ERROR));
+ } catch (IOException e) {
+ conn.send(pushFilePushResponse(pushId, ERROR_FAILED_TO_WRITE));
+ try {
+ filePush.release();
+ } catch (IOException ex) {
+ Ln.w("Failed to release stream for file: \"" + filePush.getFileName() + "\"");
+ }
+ }
+ }
+ private static void handleFinish(WebSocket conn, ControlMessage msg) {
+ FilePush filePush = checkPushId(conn, msg);
+ if (filePush == null) {
+ return;
+ }
+ short pushId = msg.getPushId();
+ if (filePush.isComplete()) {
+ try {
+ filePush.release();
+ conn.send(pushFilePushResponse(pushId, NO_ERROR));
+ } catch (IOException e) {
+ conn.send(pushFilePushResponse(pushId, ERROR_FAILED_TO_WRITE));
+ }
+ } else {
+ conn.send(pushFilePushResponse(pushId, ERROR_INCORRECT_SIZE));
+ }
+ }
+ private static void handleCancel(WebSocket conn, ControlMessage msg) {
+ FilePush filePush = checkPushId(conn, msg);
+ if (filePush == null) {
+ return;
+ }
+ short pushId = msg.getPushId();
+ try {
+ filePush.release();
+ conn.send(pushFilePushResponse(pushId, NO_ERROR));
+ } catch (IOException e) {
+ conn.send(pushFilePushResponse(pushId, ERROR_FAILED_TO_WRITE));
+ }
+ }
+
+ public static void cancelAllForConnection(WebSocket conn) {
+ FilePush.releaseByConnection(conn);
+ }
+
+ public static void handlePush(WebSocket conn, ControlMessage msg) {
+ int state = msg.getPushState();
+ switch (state) {
+ case ControlMessage.PUSH_STATE_NEW:
+ handleNew(conn);
+ break;
+ case ControlMessage.PUSH_STATE_APPEND:
+ handleAppend(conn, msg);
+ break;
+ case ControlMessage.PUSH_STATE_START:
+ handleStart(conn, msg);
+ break;
+ case ControlMessage.PUSH_STATE_FINISH:
+ handleFinish(conn, msg);
+ break;
+ case ControlMessage.PUSH_STATE_CANCEL:
+ handleCancel(conn, msg);
+ break;
+ default:
+ conn.send(pushFilePushResponse(msg.getPushId(), ERROR_INVALID_STATE));
+ }
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/Options.java b/server/src/main/java/com/genymobile/scrcpy/Options.java
index cf11df0f..f7711f8a 100644
--- a/server/src/main/java/com/genymobile/scrcpy/Options.java
+++ b/server/src/main/java/com/genymobile/scrcpy/Options.java
@@ -3,21 +3,27 @@ package com.genymobile.scrcpy;
import android.graphics.Rect;
public class Options {
- private Ln.Level logLevel;
+ public static final int TYPE_LOCAL_SOCKET = 1;
+ public static final int TYPE_WEB_SOCKET = 2;
+
+ private Ln.Level logLevel = Ln.Level.ERROR;
private int maxSize;
private int bitRate;
private int maxFps;
private int lockedVideoOrientation;
- private boolean tunnelForward;
+ private boolean tunnelForward = false;
private Rect crop;
private boolean sendFrameMeta; // send PTS so that the client may record properly
- private boolean control;
+ private boolean control = true;
private int displayId;
- private boolean showTouches;
- private boolean stayAwake;
+ private boolean showTouches = false;
+ private boolean stayAwake = false;
private String codecOptions;
private String encoderName;
private boolean powerOffScreenOnClose;
+ private int serverType = TYPE_LOCAL_SOCKET;
+ private int portNumber = 8886;
+ private boolean listenOnAllInterfaces = true;
public Ln.Level getLogLevel() {
return logLevel;
@@ -32,7 +38,7 @@ public class Options {
}
public void setMaxSize(int maxSize) {
- this.maxSize = maxSize;
+ this.maxSize = (maxSize / 8) * 8;
}
public int getBitRate() {
@@ -138,4 +144,44 @@ public class Options {
public boolean getPowerOffScreenOnClose() {
return this.powerOffScreenOnClose;
}
+
+ public int getServerType() {
+ return serverType;
+ }
+
+ public void setServerType(int type) {
+ if (type == TYPE_LOCAL_SOCKET || type == TYPE_WEB_SOCKET) {
+ this.serverType = type;
+ }
+ }
+
+ public void setPortNumber(int portNumber) {
+ this.portNumber = portNumber;
+ }
+
+ public int getPortNumber() {
+ return this.portNumber;
+ }
+
+ public boolean getListenOnAllInterfaces() {
+ return this.listenOnAllInterfaces;
+ }
+
+ public void setListenOnAllInterfaces(boolean value) {
+ this.listenOnAllInterfaces = value;
+ }
+
+ @Override
+ public String toString() {
+ return "Options{"
+ + "maxSize=" + maxSize
+ + ", bitRate=" + bitRate
+ + ", maxFps=" + maxFps
+ + ", tunnelForward=" + tunnelForward
+ + ", crop=" + crop
+ + ", sendFrameMeta=" + sendFrameMeta
+ + ", serverType=" + (serverType == TYPE_LOCAL_SOCKET ? "local" : "web")
+ + ", listenOnAllInterfaces=" + (this.listenOnAllInterfaces ? "true" : "false")
+ + '}';
+ }
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java b/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java
index 2f7109c5..53514e7b 100644
--- a/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java
+++ b/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java
@@ -11,7 +11,6 @@ import android.os.Build;
import android.os.IBinder;
import android.view.Surface;
-import java.io.FileDescriptor;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
@@ -19,7 +18,7 @@ import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
-public class ScreenEncoder implements Device.RotationListener {
+public class ScreenEncoder implements Connection.StreamInvalidateListener, Runnable {
private static final int DEFAULT_I_FRAME_INTERVAL = 10; // seconds
private static final int REPEAT_FRAME_DELAY_US = 100_000; // repeat after 100ms
@@ -27,55 +26,77 @@ public class ScreenEncoder implements Device.RotationListener {
private static final int NO_PTS = -1;
- private final AtomicBoolean rotationChanged = new AtomicBoolean();
+ private final AtomicBoolean streamIsInvalide = new AtomicBoolean();
private final ByteBuffer headerBuffer = ByteBuffer.allocate(12);
+ private Thread selectorThread;
- private String encoderName;
- private List codecOptions;
- private int bitRate;
- private int maxFps;
- private boolean sendFrameMeta;
private long ptsOrigin;
+ private Device device;
+ private Connection connection;
+ private VideoSettings videoSettings;
+ private MediaFormat format;
+ private int timeout = -1;
- public ScreenEncoder(boolean sendFrameMeta, int bitRate, int maxFps, List codecOptions, String encoderName) {
- this.sendFrameMeta = sendFrameMeta;
- this.bitRate = bitRate;
- this.maxFps = maxFps;
- this.codecOptions = codecOptions;
- this.encoderName = encoderName;
+ public ScreenEncoder(VideoSettings videoSettings) {
+ this.videoSettings = videoSettings;
+ updateFormat();
+ }
+
+ private void updateFormat() {
+ format = createFormat(videoSettings);
+ int maxFps = videoSettings.getMaxFps();
+ if (maxFps > 0) {
+ timeout = 1_000_000 / maxFps;
+ } else {
+ timeout = -1;
+ }
+ }
+
+ public void setConnection(Connection connection) {
+ this.connection = connection;
+ }
+
+ public void setDevice(Device device) {
+ this.device = device;
}
@Override
- public void onRotationChanged(int rotation) {
- rotationChanged.set(true);
+ public void onStreamInvalidate() {
+ Ln.d("invalidate stream");
+ streamIsInvalide.set(true);
+ updateFormat();
}
- public boolean consumeRotationChange() {
- return rotationChanged.getAndSet(false);
+ public boolean consumeStreamInvalidation() {
+ return streamIsInvalide.getAndSet(false);
}
- public void streamScreen(Device device, FileDescriptor fd) throws IOException {
+ public boolean isAlive() {
+ return selectorThread != null && selectorThread.isAlive();
+ }
+
+ public void streamScreen() throws IOException {
Workarounds.prepareMainLooper();
try {
- internalStreamScreen(device, fd);
+ internalStreamScreen();
} catch (NullPointerException e) {
// Retry with workarounds enabled:
//
//
Ln.d("Applying workarounds to avoid NullPointerException");
Workarounds.fillAppInfo();
- internalStreamScreen(device, fd);
+ internalStreamScreen();
}
}
- private void internalStreamScreen(Device device, FileDescriptor fd) throws IOException {
- MediaFormat format = createFormat(bitRate, maxFps, codecOptions);
- device.setRotationListener(this);
+ private void internalStreamScreen() throws IOException {
+ updateFormat();
+ connection.setStreamInvalidateListener(this);
boolean alive;
try {
do {
- MediaCodec codec = createCodec(encoderName);
+ MediaCodec codec = createCodec(videoSettings.getEncoderName());
IBinder display = createDisplay();
ScreenInfo screenInfo = device.getScreenInfo();
Rect contentRect = screenInfo.getContentRect();
@@ -92,7 +113,7 @@ public class ScreenEncoder implements Device.RotationListener {
setDisplaySurface(display, surface, videoRotation, contentRect, unlockedVideoRect, layerStack);
codec.start();
try {
- alive = encode(codec, fd);
+ alive = encode(codec);
// do not call stop() on exception, it would trigger an IllegalStateException
codec.stop();
} finally {
@@ -102,30 +123,30 @@ public class ScreenEncoder implements Device.RotationListener {
}
} while (alive);
} finally {
- device.setRotationListener(null);
+ connection.setStreamInvalidateListener(null);
}
}
- private boolean encode(MediaCodec codec, FileDescriptor fd) throws IOException {
+ private boolean encode(MediaCodec codec) throws IOException {
boolean eof = false;
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
- while (!consumeRotationChange() && !eof) {
- int outputBufferId = codec.dequeueOutputBuffer(bufferInfo, -1);
+ while (!consumeStreamInvalidation() && !eof && connection.hasConnections()) {
+ int outputBufferId = codec.dequeueOutputBuffer(bufferInfo, timeout);
eof = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
try {
- if (consumeRotationChange()) {
+ if (consumeStreamInvalidation()) {
// must restart encoding with new size
break;
}
if (outputBufferId >= 0) {
ByteBuffer codecBuffer = codec.getOutputBuffer(outputBufferId);
- if (sendFrameMeta) {
- writeFrameMeta(fd, bufferInfo, codecBuffer.remaining());
+ if (videoSettings.getSendFrameMeta()) {
+ writeFrameMeta(bufferInfo, codecBuffer.remaining());
}
- IO.writeFully(fd, codecBuffer);
+ connection.send(codecBuffer);
}
} finally {
if (outputBufferId >= 0) {
@@ -134,10 +155,10 @@ public class ScreenEncoder implements Device.RotationListener {
}
}
- return !eof;
+ return !eof && connection.hasConnections();
}
- private void writeFrameMeta(FileDescriptor fd, MediaCodec.BufferInfo bufferInfo, int packetSize) throws IOException {
+ private void writeFrameMeta(MediaCodec.BufferInfo bufferInfo, int packetSize) throws IOException {
headerBuffer.clear();
long pts;
@@ -153,10 +174,10 @@ public class ScreenEncoder implements Device.RotationListener {
headerBuffer.putLong(pts);
headerBuffer.putInt(packetSize);
headerBuffer.flip();
- IO.writeFully(fd, headerBuffer);
+ connection.send(headerBuffer);
}
- private static MediaCodecInfo[] listEncoders() {
+ public static MediaCodecInfo[] listEncoders() {
List result = new ArrayList<>();
MediaCodecList list = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
for (MediaCodecInfo codecInfo : list.getCodecInfos()) {
@@ -199,14 +220,18 @@ public class ScreenEncoder implements Device.RotationListener {
Ln.d("Codec option set: " + key + " (" + value.getClass().getSimpleName() + ") = " + value);
}
- private static MediaFormat createFormat(int bitRate, int maxFps, List codecOptions) {
+ private static MediaFormat createFormat(VideoSettings videoSettings) {
+ int bitRate = videoSettings.getBitRate();
+ int maxFps = videoSettings.getMaxFps();
+ int iFrameInterval = videoSettings.getIFrameInterval();
+ List codecOptions = videoSettings.getCodecOptions();
MediaFormat format = new MediaFormat();
format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_VIDEO_AVC);
format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate);
// must be present to configure the encoder, but does not impact the actual frame rate, which is variable
format.setInteger(MediaFormat.KEY_FRAME_RATE, 60);
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
- format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, DEFAULT_I_FRAME_INTERVAL);
+ format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval);
// display the very first frame, and recover from bad quality when no new frames
format.setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, REPEAT_FRAME_DELAY_US); // µs
if (maxFps > 0) {
@@ -256,4 +281,28 @@ public class ScreenEncoder implements Device.RotationListener {
private static void destroyDisplay(IBinder display) {
SurfaceControl.destroyDisplay(display);
}
+
+ @Override
+ public void run() {
+ synchronized (this) {
+ if (selectorThread != null && selectorThread.isAlive()) {
+ throw new IllegalStateException(getClass().getName() + " can only be started once.");
+ }
+ selectorThread = Thread.currentThread();
+ }
+ try {
+ this.streamScreen();
+ } catch (IOException e) {
+ Ln.e("Failed to start screen recorder", e);
+ }
+ }
+
+ public void start(Device device, Connection connection) {
+ this.device = device;
+ this.connection = connection;
+ if (selectorThread != null && selectorThread.isAlive()) {
+ throw new IllegalStateException(getClass().getName() + " can only be started once.");
+ }
+ new Thread(this).start();
+ }
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/ScreenInfo.java b/server/src/main/java/com/genymobile/scrcpy/ScreenInfo.java
index c27322ef..36dbbe37 100644
--- a/server/src/main/java/com/genymobile/scrcpy/ScreenInfo.java
+++ b/server/src/main/java/com/genymobile/scrcpy/ScreenInfo.java
@@ -2,6 +2,8 @@ package com.genymobile.scrcpy;
import android.graphics.Rect;
+import java.nio.ByteBuffer;
+
public final class ScreenInfo {
/**
* Device (physical) size, possibly cropped
@@ -80,7 +82,9 @@ public final class ScreenInfo {
return new ScreenInfo(newContentRect, newUnlockedVideoSize, newDeviceRotation, lockedVideoOrientation);
}
- public static ScreenInfo computeScreenInfo(DisplayInfo displayInfo, Rect crop, int maxSize, int lockedVideoOrientation) {
+ public static ScreenInfo computeScreenInfo(DisplayInfo displayInfo, VideoSettings videoSettings) {
+ int lockedVideoOrientation = videoSettings.getLockedVideoOrientation();
+ Rect crop = videoSettings.getCrop();
int rotation = displayInfo.getRotation();
if (lockedVideoOrientation == Device.LOCK_VIDEO_ORIENTATION_INITIAL) {
@@ -102,7 +106,8 @@ public final class ScreenInfo {
}
}
- Size videoSize = computeVideoSize(contentRect.width(), contentRect.height(), maxSize);
+ Size bounds = videoSettings.getBounds();
+ Size videoSize = computeVideoSize(contentRect.width(), contentRect.height(), bounds);
return new ScreenInfo(contentRect, videoSize, rotation, lockedVideoOrientation);
}
@@ -110,31 +115,35 @@ public final class ScreenInfo {
return rect.width() + ":" + rect.height() + ":" + rect.left + ":" + rect.top;
}
- private static Size computeVideoSize(int w, int h, int maxSize) {
- // Compute the video size and the padding of the content inside this video.
- // Principle:
- // - scale down the great side of the screen to maxSize (if necessary);
- // - scale down the other side so that the aspect ratio is preserved;
- // - round this value to the nearest multiple of 8 (H.264 only accepts multiples of 8)
- w &= ~7; // in case it's not a multiple of 8
- h &= ~7;
- if (maxSize > 0) {
- if (BuildConfig.DEBUG && maxSize % 8 != 0) {
- throw new AssertionError("Max size must be a multiple of 8");
- }
- boolean portrait = h > w;
- int major = portrait ? h : w;
- int minor = portrait ? w : h;
- if (major > maxSize) {
- int minorExact = minor * maxSize / major;
- // +4 to round the value to the nearest multiple of 8
- minor = (minorExact + 4) & ~7;
- major = maxSize;
- }
- w = portrait ? minor : major;
- h = portrait ? major : minor;
+ private static Size computeVideoSize(int w, int h, Size bounds) {
+ if (bounds == null) {
+ w &= ~15; // in case it's not a multiple of 16
+ h &= ~15;
+ return new Size(w, h);
}
- return new Size(w, h);
+ int boundsWidth = bounds.getWidth();
+ int boundsHeight = bounds.getHeight();
+ int scaledHeight;
+ int scaledWidth;
+ if (boundsWidth > w) {
+ scaledHeight = h;
+ } else {
+ scaledHeight = boundsWidth * h / w;
+ }
+ if (boundsHeight > scaledHeight) {
+ boundsHeight = scaledHeight;
+ }
+ if (boundsHeight == h) {
+ scaledWidth = w;
+ } else {
+ scaledWidth = boundsHeight * w / h;
+ }
+ if (boundsWidth > scaledWidth) {
+ boundsWidth = scaledWidth;
+ }
+ boundsWidth &= ~15;
+ boundsHeight &= ~15;
+ return new Size(boundsWidth, boundsHeight);
}
private static Rect flipRect(Rect crop) {
@@ -166,4 +175,16 @@ public final class ScreenInfo {
}
return (lockedVideoOrientation + 4 - deviceRotation) % 4;
}
+
+ public byte[] toByteArray() {
+ ByteBuffer temp = ByteBuffer.allocate(6 * 4 + 1);
+ temp.putInt(contentRect.left);
+ temp.putInt(contentRect.top);
+ temp.putInt(contentRect.right);
+ temp.putInt(contentRect.bottom);
+ temp.putInt(unlockedVideoSize.getWidth());
+ temp.putInt(unlockedVideoSize.getHeight());
+ temp.put((byte) getVideoRotation());
+ return temp.array();
+ }
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/Server.java b/server/src/main/java/com/genymobile/scrcpy/Server.java
index fdd9db88..51cafb8d 100644
--- a/server/src/main/java/com/genymobile/scrcpy/Server.java
+++ b/server/src/main/java/com/genymobile/scrcpy/Server.java
@@ -1,15 +1,10 @@
package com.genymobile.scrcpy;
-import com.genymobile.scrcpy.wrappers.ContentProvider;
-
import android.graphics.Rect;
import android.media.MediaCodec;
import android.media.MediaCodecInfo;
-import android.os.BatteryManager;
import android.os.Build;
-import java.io.IOException;
-import java.util.List;
import java.util.Locale;
public final class Server {
@@ -19,112 +14,7 @@ public final class Server {
// not instantiable
}
- private static void scrcpy(Options options) throws IOException {
- Ln.i("Device: " + Build.MANUFACTURER + " " + Build.MODEL + " (Android " + Build.VERSION.RELEASE + ")");
- final Device device = new Device(options);
- List codecOptions = CodecOption.parse(options.getCodecOptions());
-
- boolean mustDisableShowTouchesOnCleanUp = false;
- int restoreStayOn = -1;
- if (options.getShowTouches() || options.getStayAwake()) {
- try (ContentProvider settings = Device.createSettingsProvider()) {
- if (options.getShowTouches()) {
- String oldValue = settings.getAndPutValue(ContentProvider.TABLE_SYSTEM, "show_touches", "1");
- // If "show touches" was disabled, it must be disabled back on clean up
- mustDisableShowTouchesOnCleanUp = !"1".equals(oldValue);
- }
-
- if (options.getStayAwake()) {
- int stayOn = BatteryManager.BATTERY_PLUGGED_AC | BatteryManager.BATTERY_PLUGGED_USB | BatteryManager.BATTERY_PLUGGED_WIRELESS;
- String oldValue = settings.getAndPutValue(ContentProvider.TABLE_GLOBAL, "stay_on_while_plugged_in", String.valueOf(stayOn));
- try {
- restoreStayOn = Integer.parseInt(oldValue);
- if (restoreStayOn == stayOn) {
- // No need to restore
- restoreStayOn = -1;
- }
- } catch (NumberFormatException e) {
- restoreStayOn = 0;
- }
- }
- }
- }
-
- CleanUp.configure(options.getDisplayId(), restoreStayOn, mustDisableShowTouchesOnCleanUp, true, options.getPowerOffScreenOnClose());
-
- boolean tunnelForward = options.isTunnelForward();
-
- try (DesktopConnection connection = DesktopConnection.open(device, tunnelForward)) {
- ScreenEncoder screenEncoder = new ScreenEncoder(options.getSendFrameMeta(), options.getBitRate(), options.getMaxFps(), codecOptions,
- options.getEncoderName());
-
- Thread controllerThread = null;
- Thread deviceMessageSenderThread = null;
- if (options.getControl()) {
- final Controller controller = new Controller(device, connection);
-
- // asynchronous
- controllerThread = startController(controller);
- deviceMessageSenderThread = startDeviceMessageSender(controller.getSender());
-
- device.setClipboardListener(new Device.ClipboardListener() {
- @Override
- public void onClipboardTextChanged(String text) {
- controller.getSender().pushClipboardText(text);
- }
- });
- }
-
- try {
- // synchronous
- screenEncoder.streamScreen(device, connection.getVideoFd());
- } catch (IOException e) {
- // this is expected on close
- Ln.d("Screen streaming stopped");
- } finally {
- if (controllerThread != null) {
- controllerThread.interrupt();
- }
- if (deviceMessageSenderThread != null) {
- deviceMessageSenderThread.interrupt();
- }
- }
- }
- }
-
- private static Thread startController(final Controller controller) {
- Thread thread = new Thread(new Runnable() {
- @Override
- public void run() {
- try {
- controller.control();
- } catch (IOException e) {
- // this is expected on close
- Ln.d("Controller stopped");
- }
- }
- });
- thread.start();
- return thread;
- }
-
- private static Thread startDeviceMessageSender(final DeviceMessageSender sender) {
- Thread thread = new Thread(new Runnable() {
- @Override
- public void run() {
- try {
- sender.loop();
- } catch (IOException | InterruptedException e) {
- // this is expected on close
- Ln.d("Device message sender stopped");
- }
- }
- });
- thread.start();
- return thread;
- }
-
- private static Options createOptions(String... args) {
+ private static void parseArguments(Options options, VideoSettings videoSettings, String... args) {
if (args.length < 1) {
throw new IllegalArgumentException("Missing client version");
}
@@ -135,43 +25,60 @@ public final class Server {
"The server version (" + BuildConfig.VERSION_NAME + ") does not match the client " + "(" + clientVersion + ")");
}
+ if (args[1].toLowerCase().equals("web")) {
+ options.setServerType(Options.TYPE_WEB_SOCKET);
+ if (args.length > 2) {
+ Ln.Level level = Ln.Level.valueOf(args[2].toUpperCase(Locale.ENGLISH));
+ options.setLogLevel(level);
+ }
+ if (args.length > 3) {
+ int portNumber = Integer.parseInt(args[3]);
+ options.setPortNumber(portNumber);
+ }
+ if (args.length > 4) {
+ boolean listenOnAllInterfaces = Boolean.parseBoolean(args[4]);
+ options.setListenOnAllInterfaces(listenOnAllInterfaces);
+ }
+ return;
+ }
+
final int expectedParameters = 16;
if (args.length != expectedParameters) {
throw new IllegalArgumentException("Expecting " + expectedParameters + " parameters");
}
- Options options = new Options();
-
Ln.Level level = Ln.Level.valueOf(args[1].toUpperCase(Locale.ENGLISH));
options.setLogLevel(level);
- int maxSize = Integer.parseInt(args[2]) & ~7; // multiple of 8
- options.setMaxSize(maxSize);
+ int maxSize = Integer.parseInt(args[2]);
+ if (maxSize != 0) {
+ videoSettings.setBounds(maxSize, maxSize);
+ }
int bitRate = Integer.parseInt(args[3]);
- options.setBitRate(bitRate);
+ videoSettings.setBitRate(bitRate);
int maxFps = Integer.parseInt(args[4]);
- options.setMaxFps(maxFps);
+ videoSettings.setMaxFps(maxFps);
int lockedVideoOrientation = Integer.parseInt(args[5]);
- options.setLockedVideoOrientation(lockedVideoOrientation);
+ videoSettings.setLockedVideoOrientation(lockedVideoOrientation);
// use "adb forward" instead of "adb tunnel"? (so the server must listen)
boolean tunnelForward = Boolean.parseBoolean(args[6]);
options.setTunnelForward(tunnelForward);
Rect crop = parseCrop(args[7]);
- options.setCrop(crop);
+ videoSettings.setCrop(crop);
boolean sendFrameMeta = Boolean.parseBoolean(args[8]);
- options.setSendFrameMeta(sendFrameMeta);
+ videoSettings.setSendFrameMeta(sendFrameMeta);
boolean control = Boolean.parseBoolean(args[9]);
options.setControl(control);
int displayId = Integer.parseInt(args[10]);
- options.setDisplayId(displayId);
+ videoSettings.setDisplayId(displayId);
boolean showTouches = Boolean.parseBoolean(args[11]);
options.setShowTouches(showTouches);
@@ -181,14 +88,13 @@ public final class Server {
String codecOptions = args[13];
options.setCodecOptions(codecOptions);
+ videoSettings.setCodecOptions(codecOptions);
String encoderName = "-".equals(args[14]) ? null : args[14];
- options.setEncoderName(encoderName);
+ videoSettings.setEncoderName(encoderName);
boolean powerOffScreenOnClose = Boolean.parseBoolean(args[15]);
options.setPowerOffScreenOnClose(powerOffScreenOnClose);
-
- return options;
}
private static Rect parseCrop(String crop) {
@@ -248,10 +154,16 @@ public final class Server {
}
});
- Options options = createOptions(args);
-
+ Options options = new Options();
+ VideoSettings videoSettings = new VideoSettings();
+ parseArguments(options, videoSettings, args);
Ln.initLogLevel(options.getLogLevel());
-
- scrcpy(options);
+ if (options.getServerType() == Options.TYPE_LOCAL_SOCKET) {
+ new DesktopConnection(options, videoSettings);
+ } else if (options.getServerType() == Options.TYPE_WEB_SOCKET) {
+ WSServer wsServer = new WSServer(options);
+ wsServer.setReuseAddr(true);
+ wsServer.run();
+ }
}
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/VideoSettings.java b/server/src/main/java/com/genymobile/scrcpy/VideoSettings.java
new file mode 100644
index 00000000..bc75efca
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/VideoSettings.java
@@ -0,0 +1,290 @@
+package com.genymobile.scrcpy;
+
+import android.graphics.Rect;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Objects;
+
+public class VideoSettings {
+ private static final int DEFAULT_BIT_RATE = 8000000;
+ private static final byte DEFAULT_MAX_FPS = 60;
+ private static final byte DEFAULT_I_FRAME_INTERVAL = 10; // seconds
+
+ private Size bounds;
+ private int bitRate = DEFAULT_BIT_RATE;
+ private int maxFps;
+ private int lockedVideoOrientation;
+ private byte iFrameInterval = DEFAULT_I_FRAME_INTERVAL;
+ private Rect crop;
+ private boolean sendFrameMeta; // send PTS so that the client may record properly
+ private int displayId;
+ private String codecOptionsString;
+ private List codecOptions;
+ private String encoderName;
+
+ public int getBitRate() {
+ return bitRate;
+ }
+
+ public void setBitRate(int bitRate) {
+ this.bitRate = bitRate;
+ }
+
+ public int getIFrameInterval() {
+ return iFrameInterval;
+ }
+
+ public void setIFrameInterval(byte iFrameInterval) {
+ this.iFrameInterval = iFrameInterval;
+ };
+
+ public Rect getCrop() {
+ return crop;
+ }
+
+ public void setCrop(Rect crop) {
+ this.crop = crop;
+ }
+
+ public boolean getSendFrameMeta() {
+ return sendFrameMeta;
+ }
+
+ public void setSendFrameMeta(boolean sendFrameMeta) {
+ this.sendFrameMeta = sendFrameMeta;
+ }
+
+ public int getDisplayId() {
+ return displayId;
+ }
+
+ public void setDisplayId(int displayId) {
+ this.displayId = displayId;
+ }
+
+ public int getMaxFps() {
+ return maxFps;
+ }
+
+ public void setMaxFps(int maxFps) {
+ this.maxFps = maxFps;
+ }
+
+ public int getLockedVideoOrientation() {
+ return lockedVideoOrientation;
+ }
+
+ public void setLockedVideoOrientation(int lockedVideoOrientation) {
+ this.lockedVideoOrientation = lockedVideoOrientation;
+ }
+
+ public Size getBounds() {
+ return bounds;
+ }
+
+ public void setBounds(Size bounds) {
+ this.bounds = bounds;
+ }
+
+ public void setBounds(int width, int height) {
+ this.bounds = new Size(width & ~15, height & ~15); // multiple of 16
+ }
+
+ public List getCodecOptions() {
+ return codecOptions;
+ }
+
+ public void setCodecOptions(String codecOptionsString) {
+ this.codecOptions = CodecOption.parse(codecOptionsString);
+ if (codecOptionsString.equals("-")) {
+ this.codecOptionsString = null;
+ } else {
+ this.codecOptionsString = codecOptionsString;
+ }
+ }
+
+ public String getEncoderName() {
+ return this.encoderName;
+ }
+
+ public void setEncoderName(String encoderName) {
+ if (encoderName != null && encoderName.equals("-")) {
+ this.encoderName = null;
+ } else {
+ this.encoderName = encoderName;
+ }
+ }
+
+ public byte[] toByteArray() {
+ // 35 bytes without codec options and encoder name
+ int baseLength = 35;
+ int additionalLength = 0;
+ byte[] codeOptionsBytes = new byte[]{};
+ if (this.codecOptionsString != null) {
+ codeOptionsBytes = this.codecOptionsString.getBytes(StandardCharsets.UTF_8);
+ additionalLength += codeOptionsBytes.length;
+ }
+ byte[] encoderNameBytes = new byte[]{};
+ if (this.encoderName != null) {
+ encoderNameBytes = this.encoderName.getBytes(StandardCharsets.UTF_8);
+ additionalLength += encoderNameBytes.length;
+ }
+ ByteBuffer temp = ByteBuffer.allocate(baseLength + additionalLength);
+ temp.putInt(bitRate);
+ temp.putInt(maxFps);
+ temp.put(iFrameInterval);
+ int width = 0;
+ int height = 0;
+ if (bounds != null) {
+ width = bounds.getWidth();
+ height = bounds.getHeight();
+ }
+ temp.putShort((short) width);
+ temp.putShort((short) height);
+ int left = 0;
+ int top = 0;
+ int right = 0;
+ int bottom = 0;
+ if (crop != null) {
+ left = crop.left;
+ top = crop.top;
+ right = crop.right;
+ bottom = crop.bottom;
+ }
+ temp.putShort((short) left);
+ temp.putShort((short) top);
+ temp.putShort((short) right);
+ temp.putShort((short) bottom);
+ temp.put((byte) (sendFrameMeta ? 1 : 0));
+ temp.put((byte) lockedVideoOrientation);
+ temp.putInt(displayId);
+ temp.putInt(codeOptionsBytes.length);
+ if (codeOptionsBytes.length != 0) {
+ temp.put(codeOptionsBytes);
+ }
+ temp.putInt(encoderNameBytes.length);
+ if (encoderNameBytes.length != 0) {
+ temp.put(encoderNameBytes);
+ }
+ return temp.array();
+ }
+
+ public void merge(VideoSettings source) {
+ codecOptions = source.codecOptions;
+ codecOptionsString = source.codecOptionsString;
+ encoderName = source.encoderName;
+ bitRate = source.bitRate;
+ maxFps = source.maxFps;
+ iFrameInterval = source.iFrameInterval;
+ bounds = source.bounds;
+ crop = source.crop;
+ sendFrameMeta = source.sendFrameMeta;
+ lockedVideoOrientation = source.lockedVideoOrientation;
+ displayId = source.displayId;
+ }
+
+ public static VideoSettings fromByteArray(byte[] bytes) {
+ VideoSettings videoSettings = new VideoSettings();
+ mergeFromByteArray(videoSettings, bytes);
+ return videoSettings;
+ }
+
+ public static void mergeFromByteArray(VideoSettings videoSettings, byte[] bytes) {
+ ByteBuffer data = ByteBuffer.wrap(bytes);
+ int bitRate = data.getInt();
+ int maxFps = data.getInt();
+ byte iFrameInterval = data.get();
+ int width = data.getShort();
+ int height = data.getShort();
+ int left = data.getShort();
+ int top = data.getShort();
+ int right = data.getShort();
+ int bottom = data.getShort();
+ boolean sendMetaFrame = data.get() != 0;
+ int lockedVideoOrientation = data.get();
+ int displayId = data.getInt();
+ if (data.remaining() > 0) {
+ int codecOptionsLength = data.getInt();
+ if (codecOptionsLength > 0) {
+ byte[] textBuffer = new byte[codecOptionsLength];
+ data.get(textBuffer, 0, codecOptionsLength);
+ String codecOptions = new String(textBuffer, 0, codecOptionsLength, StandardCharsets.UTF_8);
+ if (!codecOptions.isEmpty()) {
+ videoSettings.setCodecOptions(codecOptions);
+ }
+ }
+ }
+ if (data.remaining() > 0) {
+ int encoderNameLength = data.getInt();
+ if (encoderNameLength > 0) {
+ byte[] textBuffer = new byte[encoderNameLength];
+ data.get(textBuffer, 0, encoderNameLength);
+ String encoderName = new String(textBuffer, 0, encoderNameLength, StandardCharsets.UTF_8);
+ if (!encoderName.isEmpty()) {
+ videoSettings.setEncoderName(encoderName);
+ }
+ }
+ }
+ videoSettings.setBitRate(bitRate);
+ videoSettings.setMaxFps(maxFps);
+ videoSettings.setIFrameInterval(iFrameInterval);
+ videoSettings.setBounds(width, height);
+ if (left == 0 && right == 0 && top == 0 && bottom == 0) {
+ videoSettings.setCrop(null);
+ } else {
+ videoSettings.setCrop(new Rect(left, top, right, bottom));
+ }
+ videoSettings.setSendFrameMeta(sendMetaFrame);
+ videoSettings.setLockedVideoOrientation(lockedVideoOrientation);
+ if (displayId > 0) {
+ videoSettings.setDisplayId(displayId);
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ VideoSettings s = (VideoSettings) o;
+ if (bitRate != s.bitRate || maxFps != s.maxFps || lockedVideoOrientation != s.lockedVideoOrientation || iFrameInterval != s.iFrameInterval
+ || sendFrameMeta != s.sendFrameMeta || displayId != s.displayId) {
+ return false;
+ }
+ if (!Objects.equals(codecOptionsString, s.codecOptionsString) || !Objects.equals(encoderName, s.encoderName)
+ || !Objects.equals(bounds, s.bounds) || !Objects.equals(crop, s.crop)) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(bitRate, maxFps, lockedVideoOrientation, iFrameInterval, sendFrameMeta,
+ displayId, Objects.hashCode(codecOptionsString), Objects.hashCode(encoderName),
+ Objects.hashCode(bounds), Objects.hashCode(crop));
+ }
+
+ @Override
+ public String toString() {
+ return "VideoSettings{"
+ + "bitRate=" + bitRate
+ + ", maxFps=" + maxFps
+ + ", iFrameInterval=" + iFrameInterval
+ + ", bounds=" + bounds
+ + ", crop=" + crop
+ + ", metaFrame=" + sendFrameMeta
+ + ", lockedVideoOrientation=" + lockedVideoOrientation
+ + ", displayId=" + displayId
+ + ", codecOptions=" + (this.codecOptionsString == null ? "-" : this.codecOptionsString)
+ + ", encoderName=" + (this.encoderName == null ? "-" : this.encoderName)
+ + "}";
+ }
+
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/WSServer.java b/server/src/main/java/com/genymobile/scrcpy/WSServer.java
new file mode 100644
index 00000000..c7dbc4db
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/WSServer.java
@@ -0,0 +1,217 @@
+package com.genymobile.scrcpy;
+
+import org.java_websocket.WebSocket;
+import org.java_websocket.framing.CloseFrame;
+import org.java_websocket.handshake.ClientHandshake;
+import org.java_websocket.server.WebSocketServer;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.net.BindException;
+import java.net.InetSocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+
+public class WSServer extends WebSocketServer {
+ private static final String PID_FILE_PATH = "/data/local/tmp/ws_scrcpy.pid";
+ public static final class SocketInfo {
+ private static final HashSet INSTANCES_BY_ID = new HashSet<>();
+ private final short id;
+ private WebSocketConnection connection;
+
+ SocketInfo(short id) {
+ this.id = id;
+ INSTANCES_BY_ID.add(id);
+ }
+
+ public static short getNextClientId() {
+ short nextClientId = 0;
+ while (INSTANCES_BY_ID.contains(++nextClientId)) {
+ if (nextClientId == Short.MAX_VALUE) {
+ return -1;
+ }
+ }
+ return nextClientId;
+ }
+
+ public short getId() {
+ return id;
+ }
+
+ public WebSocketConnection getConnection() {
+ return this.connection;
+ }
+
+ public void setConnection(WebSocketConnection connection) {
+ this.connection = connection;
+ }
+
+ public void release() {
+ INSTANCES_BY_ID.remove(id);
+ }
+ }
+
+ protected final ControlMessageReader reader = new ControlMessageReader();
+ private final Options options;
+ private static final HashMap STREAM_BY_DISPLAY_ID = new HashMap<>();
+
+ public WSServer(Options options) {
+ super(new InetSocketAddress(options.getListenOnAllInterfaces() ? "0.0.0.0" : "127.0.0.1", options.getPortNumber()));
+ this.options = options;
+ unlinkPidFile();
+ }
+
+ @Override
+ public void onOpen(WebSocket webSocket, ClientHandshake handshake) {
+ if (webSocket.isOpen()) {
+ short clientId = SocketInfo.getNextClientId();
+ if (clientId == -1) {
+ webSocket.close(CloseFrame.TRY_AGAIN_LATER);
+ return;
+ }
+ SocketInfo info = new SocketInfo(clientId);
+ webSocket.setAttachment(info);
+ WebSocketConnection.sendInitialInfo(WebSocketConnection.getInitialInfo(), webSocket, clientId);
+ Ln.d("Client entered the room!");
+ }
+ }
+
+ @Override
+ public void onClose(WebSocket webSocket, int code, String reason, boolean remote) {
+ Ln.d("Client has left the room!");
+ FilePushHandler.cancelAllForConnection(webSocket);
+ SocketInfo socketInfo = webSocket.getAttachment();
+ if (socketInfo != null) {
+ WebSocketConnection connection = socketInfo.getConnection();
+ if (connection != null) {
+ connection.leave(webSocket);
+ }
+ socketInfo.release();
+ }
+ }
+
+ @Override
+ public void onMessage(WebSocket webSocket, String message) {
+ String address = webSocket.getRemoteSocketAddress().getAddress().getHostAddress();
+ Ln.w("? Client from " + address + " says: \"" + message + "\"");
+ }
+
+ @Override
+ public void onMessage(WebSocket webSocket, ByteBuffer message) {
+ SocketInfo socketInfo = webSocket.getAttachment();
+ if (socketInfo == null) {
+ Ln.e("No info attached to connection");
+ return;
+ }
+ WebSocketConnection connection = socketInfo.getConnection();
+ String address = webSocket.getRemoteSocketAddress().getAddress().getHostAddress();
+ ControlMessage controlMessage = reader.parseEvent(message);
+ if (controlMessage != null) {
+ if (controlMessage.getType() == ControlMessage.TYPE_PUSH_FILE) {
+ FilePushHandler.handlePush(webSocket, controlMessage);
+ return;
+ }
+ if (controlMessage.getType() == ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS) {
+ VideoSettings videoSettings = controlMessage.getVideoSettings();
+ int displayId = videoSettings.getDisplayId();
+ if (connection != null) {
+ if (connection.getVideoSettings().getDisplayId() != displayId) {
+ connection.leave(webSocket);
+ }
+ }
+ joinStreamForDisplayId(webSocket, videoSettings, options, displayId, this);
+ return;
+ }
+ if (connection != null) {
+ Controller controller = connection.getController();
+ controller.handleEvent(controlMessage);
+ }
+ } else {
+ Ln.w("? Client from " + address + " sends bytes: " + message);
+ }
+ }
+
+ @Override
+ public void onError(WebSocket webSocket, Exception ex) {
+ Ln.e("WebSocket error", ex);
+ if (webSocket != null) {
+ // some errors like port binding failed may not be assignable to a specific websocket
+ FilePushHandler.cancelAllForConnection(webSocket);
+ }
+ if (ex instanceof BindException) {
+ System.exit(1);
+ }
+ }
+
+ @Override
+ public void onStart() {
+ Ln.d("Server started! " + this.getAddress().toString());
+ this.setConnectionLostTimeout(0);
+ this.setConnectionLostTimeout(100);
+ writePidFile();
+ }
+
+ private static void joinStreamForDisplayId(
+ WebSocket webSocket, VideoSettings videoSettings, Options options, int displayId, WSServer wsServer) {
+ SocketInfo socketInfo = webSocket.getAttachment();
+ WebSocketConnection connection = STREAM_BY_DISPLAY_ID.get(displayId);
+ if (connection == null) {
+ connection = new WebSocketConnection(options, videoSettings, wsServer);
+ STREAM_BY_DISPLAY_ID.put(displayId, connection);
+ }
+ socketInfo.setConnection(connection);
+ connection.join(webSocket, videoSettings);
+ }
+
+ private static void unlinkPidFile() {
+ try {
+ File pidFile = new File(PID_FILE_PATH);
+ if (pidFile.exists()) {
+ if (!pidFile.delete()) {
+ Ln.e("Failed to delete PID file");
+ }
+ }
+ } catch (Exception e) {
+ Ln.e("Failed to delete PID file:", e);
+ }
+ }
+
+ private static void writePidFile() {
+ File file = new File(PID_FILE_PATH);
+ FileOutputStream stream;
+ try {
+ stream = new FileOutputStream(file, false);
+ stream.write(Integer.toString(android.os.Process.myPid()).getBytes(StandardCharsets.UTF_8));
+ stream.close();
+ } catch (IOException e) {
+ Ln.e(e.getMessage());
+ }
+ }
+
+ public static WebSocketConnection getConnectionForDisplay(int displayId) {
+ return STREAM_BY_DISPLAY_ID.get(displayId);
+ }
+
+ public static void releaseConnectionForDisplay(int displayId) {
+ STREAM_BY_DISPLAY_ID.remove(displayId);
+ }
+
+ public void sendInitialInfoToAll() {
+ Collection webSockets = this.getConnections();
+ if (webSockets.isEmpty()) {
+ return;
+ }
+ ByteBuffer initialInfo = WebSocketConnection.getInitialInfo();
+ for (WebSocket webSocket : webSockets) {
+ SocketInfo socketInfo = webSocket.getAttachment();
+ if (socketInfo == null) {
+ continue;
+ }
+ WebSocketConnection.sendInitialInfo(initialInfo, webSocket, socketInfo.getId());
+ }
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/WebSocketConnection.java b/server/src/main/java/com/genymobile/scrcpy/WebSocketConnection.java
new file mode 100644
index 00000000..f5fdc207
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/WebSocketConnection.java
@@ -0,0 +1,209 @@
+package com.genymobile.scrcpy;
+
+import android.media.MediaCodecInfo;
+
+import org.java_websocket.WebSocket;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+
+public class WebSocketConnection extends Connection {
+ private static final byte[] MAGIC_BYTES_INITIAL = "scrcpy_initial".getBytes(StandardCharsets.UTF_8);
+ private static final byte[] MAGIC_BYTES_MESSAGE = "scrcpy_message".getBytes(StandardCharsets.UTF_8);
+ private static final byte[] DEVICE_NAME_BYTES = Device.getDeviceName().getBytes(StandardCharsets.UTF_8);
+ private final WSServer wsServer;
+ private final HashSet sockets = new HashSet<>();
+ private ScreenEncoder screenEncoder;
+
+ public WebSocketConnection(Options options, VideoSettings videoSettings, WSServer wsServer) {
+ super(options, videoSettings);
+ this.wsServer = wsServer;
+ }
+
+ public void join(WebSocket webSocket, VideoSettings videoSettings) {
+ sockets.add(webSocket);
+ boolean changed = setVideoSettings(videoSettings);
+ wsServer.sendInitialInfoToAll();
+ if (!Device.isScreenOn()) {
+ controller.turnScreenOn();
+ }
+ if (screenEncoder == null || !screenEncoder.isAlive()) {
+ Ln.d("First connection. Start new encoder.");
+ device.setRotationListener(this);
+ screenEncoder = new ScreenEncoder(videoSettings);
+ screenEncoder.start(device, this);
+ } else {
+ if (!changed) {
+ if (this.streamInvalidateListener != null) {
+ streamInvalidateListener.onStreamInvalidate();
+ }
+ }
+ }
+ }
+
+ public void leave(WebSocket webSocket) {
+ sockets.remove(webSocket);
+ if (sockets.isEmpty()) {
+ Ln.d("Last client has left");
+ this.release();
+ }
+ wsServer.sendInitialInfoToAll();
+ }
+
+ public static ByteBuffer deviceMessageToByteBuffer(DeviceMessage msg) {
+ ByteBuffer buffer = ByteBuffer.wrap(msg.writeToByteArray(MAGIC_BYTES_MESSAGE.length));
+ buffer.put(MAGIC_BYTES_MESSAGE);
+ buffer.rewind();
+ return buffer;
+ }
+
+ @Override
+ void send(ByteBuffer data) {
+ if (sockets.isEmpty()) {
+ return;
+ }
+ synchronized (sockets) {
+ for (WebSocket webSocket : sockets) {
+ WSServer.SocketInfo info = webSocket.getAttachment();
+ if (!webSocket.isOpen() || info == null) {
+ continue;
+ }
+ webSocket.send(data);
+ }
+ }
+ }
+
+ public static void sendInitialInfo(ByteBuffer initialInfo, WebSocket webSocket, int clientId) {
+ initialInfo.position(initialInfo.capacity() - 4);
+ initialInfo.putInt(clientId);
+ initialInfo.rewind();
+ webSocket.send(initialInfo);
+ }
+
+ public void sendDeviceMessage(DeviceMessage msg) {
+ ByteBuffer buffer = deviceMessageToByteBuffer(msg);
+ send(buffer);
+ }
+
+ @Override
+ public boolean hasConnections() {
+ return sockets.size() > 0;
+ }
+
+ @Override
+ public void close() throws Exception {
+// wsServer.stop();
+ }
+
+ public VideoSettings getVideoSettings() {
+ return videoSettings;
+ }
+
+ public Controller getController() {
+ return controller;
+ }
+
+ public Device getDevice() {
+ return device;
+ }
+
+ @SuppressWarnings("checkstyle:MagicNumber")
+ public static ByteBuffer getInitialInfo() {
+ int baseLength = MAGIC_BYTES_INITIAL.length
+ + DEVICE_NAME_FIELD_LENGTH
+ + 4 // displays count
+ + 4; // client id
+ int additionalLength = 0;
+ int[] displayIds = Device.getDisplayIds();
+ List displayInfoList = new ArrayList<>();
+ HashMap connectionsCount = new HashMap<>();
+ HashMap displayInfoMap = new HashMap<>();
+ HashMap videoSettingsBytesMap = new HashMap<>();
+ HashMap screenInfoBytesMap = new HashMap<>();
+
+ for (int displayId : displayIds) {
+ DisplayInfo displayInfo = Device.getDisplayInfo(displayId);
+ displayInfoList.add(displayId, displayInfo);
+ byte[] displayInfoBytes = displayInfo.toByteArray();
+ additionalLength += displayInfoBytes.length;
+ displayInfoMap.put(displayId, displayInfoBytes);
+ WebSocketConnection connection = WSServer.getConnectionForDisplay(displayId);
+ additionalLength += 4; // for connection.connections.size()
+ additionalLength += 4; // for screenInfoBytes.length
+ additionalLength += 4; // for videoSettingsBytes.length
+ if (connection != null) {
+ connectionsCount.put(displayId, connection.sockets.size());
+ byte[] screenInfoBytes = connection.getDevice().getScreenInfo().toByteArray();
+ additionalLength += screenInfoBytes.length;
+ screenInfoBytesMap.put(displayId, screenInfoBytes);
+ byte[] videoSettingsBytes = connection.getVideoSettings().toByteArray();
+ additionalLength += videoSettingsBytes.length;
+ videoSettingsBytesMap.put(displayId, videoSettingsBytes);
+ }
+ }
+
+ MediaCodecInfo[] encoders = ScreenEncoder.listEncoders();
+ List encodersNames = new ArrayList<>();
+ if (encoders != null && encoders.length > 0) {
+ additionalLength += 4;
+ for (MediaCodecInfo encoder : encoders) {
+ byte[] nameBytes = encoder.getName().getBytes(StandardCharsets.UTF_8);
+ additionalLength += 4 + nameBytes.length;
+ encodersNames.add(nameBytes);
+ }
+ }
+
+ byte[] fullBytes = new byte[baseLength + additionalLength];
+ ByteBuffer initialInfo = ByteBuffer.wrap(fullBytes);
+ initialInfo.put(MAGIC_BYTES_INITIAL);
+ initialInfo.put(DEVICE_NAME_BYTES, 0, Math.min(DEVICE_NAME_FIELD_LENGTH - 1, DEVICE_NAME_BYTES.length));
+ initialInfo.position(MAGIC_BYTES_INITIAL.length + DEVICE_NAME_FIELD_LENGTH);
+ initialInfo.putInt(displayIds.length);
+ for (DisplayInfo displayInfo : displayInfoList) {
+ int displayId = displayInfo.getDisplayId();
+ if (displayInfoMap.containsKey(displayId)) {
+ initialInfo.put(displayInfoMap.get(displayId));
+ }
+ int count = 0;
+ if (connectionsCount.containsKey(displayId)) {
+ count = connectionsCount.get(displayId);
+ }
+ initialInfo.putInt(count);
+ if (screenInfoBytesMap.containsKey(displayId)) {
+ byte[] screenInfo = screenInfoBytesMap.get(displayId);
+ initialInfo.putInt(screenInfo.length);
+ initialInfo.put(screenInfo);
+ } else {
+ initialInfo.putInt(0);
+ }
+ if (videoSettingsBytesMap.containsKey(displayId)) {
+ byte[] videoSettings = videoSettingsBytesMap.get(displayId);
+ initialInfo.putInt(videoSettings.length);
+ initialInfo.put(videoSettings);
+ } else {
+ initialInfo.putInt(0);
+ }
+ }
+ initialInfo.putInt(encodersNames.size());
+ for (byte[] encoderNameBytes : encodersNames) {
+ initialInfo.putInt(encoderNameBytes.length);
+ initialInfo.put(encoderNameBytes);
+ }
+
+ return initialInfo;
+ }
+
+ public void onRotationChanged(int rotation) {
+ super.onRotationChanged(rotation);
+ wsServer.sendInitialInfoToAll();
+ }
+
+ private void release() {
+ WSServer.releaseConnectionForDisplay(this.videoSettings.getDisplayId());
+ // encoder will stop itself after checking .hasConnections()
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/Workarounds.java b/server/src/main/java/com/genymobile/scrcpy/Workarounds.java
index 0f473bc1..36866ffb 100644
--- a/server/src/main/java/com/genymobile/scrcpy/Workarounds.java
+++ b/server/src/main/java/com/genymobile/scrcpy/Workarounds.java
@@ -12,6 +12,7 @@ import java.lang.reflect.Field;
import java.lang.reflect.Method;
public final class Workarounds {
+ private static boolean looperPrepared = false;
private Workarounds() {
// not instantiable
}
@@ -26,6 +27,10 @@ public final class Workarounds {
// "Attempt to read from field 'android.os.MessageQueue android.os.Looper.mQueue'
// on a null object reference"
//
+ if (looperPrepared) {
+ return;
+ }
+ looperPrepared = true;
Looper.prepareMainLooper();
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/ClipboardManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/ClipboardManager.java
index e25b6e99..3b3ead0c 100644
--- a/server/src/main/java/com/genymobile/scrcpy/wrappers/ClipboardManager.java
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/ClipboardManager.java
@@ -15,6 +15,7 @@ public class ClipboardManager {
private Method getPrimaryClipMethod;
private Method setPrimaryClipMethod;
private Method addPrimaryClipChangedListener;
+ private Method removePrimaryClipChangedListener;
public ClipboardManager(IInterface manager) {
this.manager = manager;
@@ -116,4 +117,39 @@ public class ClipboardManager {
return false;
}
}
+
+
+ private static void removePrimaryClipChangedListener(Method method, IInterface manager, IOnPrimaryClipChangedListener listener)
+ throws InvocationTargetException, IllegalAccessException {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ method.invoke(manager, listener, ServiceManager.PACKAGE_NAME);
+ } else {
+ method.invoke(manager, listener, ServiceManager.PACKAGE_NAME, ServiceManager.USER_ID);
+ }
+ }
+
+ private Method getRemovePrimaryClipChangedListener() throws NoSuchMethodException {
+ if (removePrimaryClipChangedListener == null) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ removePrimaryClipChangedListener = manager.getClass()
+ .getMethod("removePrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class);
+ } else {
+ removePrimaryClipChangedListener = manager.getClass()
+ .getMethod("removePrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class, int.class);
+ }
+ }
+ return removePrimaryClipChangedListener;
+ }
+
+ public boolean removePrimaryClipChangedListener(IOnPrimaryClipChangedListener listener) {
+ try {
+ Method method = getRemovePrimaryClipChangedListener();
+ removePrimaryClipChangedListener(method, manager, listener);
+ return true;
+ } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
+ Ln.e("Could not invoke method", e);
+ return false;
+ }
+ }
+
}
diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/WindowManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/WindowManager.java
index faa366a5..368fe4c3 100644
--- a/server/src/main/java/com/genymobile/scrcpy/wrappers/WindowManager.java
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/WindowManager.java
@@ -14,6 +14,7 @@ public final class WindowManager {
private Method freezeRotationMethod;
private Method isRotationFrozenMethod;
private Method thawRotationMethod;
+ private Method removeRotationWatcherMethod;
public WindowManager(IInterface manager) {
this.manager = manager;
@@ -55,6 +56,13 @@ public final class WindowManager {
return thawRotationMethod;
}
+ private Method getRemoveRotationWatcherMethod() throws NoSuchMethodException {
+ if (removeRotationWatcherMethod == null) {
+ removeRotationWatcherMethod = manager.getClass().getMethod("removeRotationWatcher");
+ }
+ return removeRotationWatcherMethod;
+ }
+
public int getRotation() {
try {
Method method = getGetRotationMethod();
@@ -108,4 +116,13 @@ public final class WindowManager {
throw new AssertionError(e);
}
}
+
+ public void unregisterRotationWatcher(IRotationWatcher rotationWatcher) {
+ try {
+ Method method = getRemoveRotationWatcherMethod();
+ method.invoke(manager, rotationWatcher);
+ } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
+ Ln.e("Could not invoke method", e);
+ }
+ }
}