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); + } + } }